From 347c3528cab099f4d56d62ec98f57569ef6e3664 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 30 Jul 2026 15:46:33 -0700 Subject: [PATCH 01/14] Update Learning Mode API contracts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a8860a9-bb20-48ef-91a1-3da8e34b92fb --- .github/copilot-instructions.md | 2 +- docs/learning-mode/capabilities.md | 5 + docs/schema.md | 1 + .../ProcessSecurityEnvironment.fbs | 62 ++ src/Cargo.lock | 10 +- src/Cargo.toml | 4 +- src/backends/appcontainer/common/Cargo.toml | 1 + .../common/src/base_container_runner.rs | 429 +++++++------ src/backends/learning_mode/windows/Cargo.toml | 2 +- .../windows/examples/lm_capture.rs | 31 +- .../windows/examples/lm_probe.rs | 6 +- src/backends/learning_mode/windows/src/ffi.rs | 276 +++++++-- src/backends/learning_mode/windows/src/lib.rs | 60 +- .../learning_mode/windows/src/lifecycle.rs | 127 +--- .../learning_mode/windows/src/secenv.rs | 146 ++--- .../Cargo.toml | 10 + .../README.md | 12 + .../regenerate.ps1 | 65 ++ .../src/lib.rs | 28 + .../destination_rule_generated.rs | 194 ++++++ .../endpoint_policy_generated.rs | 242 ++++++++ .../endpoint_rule_generated.rs | 216 +++++++ .../filter_action_generated.rs | 92 +++ .../ip_protocol_generated.rs | 105 ++++ .../ip_subnet_generated.rs | 182 ++++++ .../network_policy_generated.rs | 242 ++++++++ .../port_rule_generated.rs | 198 ++++++ .../process_security_environment_generated.rs | 565 ++++++++++++++++++ .../proxy_info_generated.rs | 137 +++++ .../schema_version_generated.rs | 152 +++++ 30 files changed, 3093 insertions(+), 509 deletions(-) create mode 100644 external/windows-sdk/ProcessSecurityEnvironment.fbs create mode 100644 src/core/generated/process_security_environment_specification/Cargo.toml create mode 100644 src/core/generated/process_security_environment_specification/README.md create mode 100644 src/core/generated/process_security_environment_specification/regenerate.ps1 create mode 100644 src/core/generated/process_security_environment_specification/src/lib.rs create mode 100644 src/core/generated/process_security_environment_specification/src/process_security_environment_layout/destination_rule_generated.rs create mode 100644 src/core/generated/process_security_environment_specification/src/process_security_environment_layout/endpoint_policy_generated.rs create mode 100644 src/core/generated/process_security_environment_specification/src/process_security_environment_layout/endpoint_rule_generated.rs create mode 100644 src/core/generated/process_security_environment_specification/src/process_security_environment_layout/filter_action_generated.rs create mode 100644 src/core/generated/process_security_environment_specification/src/process_security_environment_layout/ip_protocol_generated.rs create mode 100644 src/core/generated/process_security_environment_specification/src/process_security_environment_layout/ip_subnet_generated.rs create mode 100644 src/core/generated/process_security_environment_specification/src/process_security_environment_layout/network_policy_generated.rs create mode 100644 src/core/generated/process_security_environment_specification/src/process_security_environment_layout/port_rule_generated.rs create mode 100644 src/core/generated/process_security_environment_specification/src/process_security_environment_layout/process_security_environment_generated.rs create mode 100644 src/core/generated/process_security_environment_specification/src/process_security_environment_layout/proxy_info_generated.rs create mode 100644 src/core/generated/process_security_environment_specification/src/process_security_environment_layout/schema_version_generated.rs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 022ad111d..880e462fd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -201,7 +201,7 @@ The workspace is organized into six top-level directories under `src/`: - `wxc_common` is the **cross-platform foundation**: config parsing, models, errors, logger, `ScriptRunner` / `StatefulSandboxBackend` traits, state-aware dispatch helpers, validators, ids, ui-policy, encoding. Plus a few thin Windows API helpers shared by host tools and backends (`process_util`, `string_util`, `filesystem_dacl`, `diagnostic`). It must not depend on any `backends/*` crate. - Each Windows containment backend lives in its own `backends/*/common` crate (e.g. `appcontainer_common`, `windows_sandbox_common`, `isolation_session_common`, `hyperlight_common`, `nanvix_runner`). Backend crates depend on `wxc_common`; there are no cross-edges between backend crates. Windows Sandbox additionally has `windows_sandbox_lifecycle`, which owns the one-shot and state-aware runners and depends on `windows_sandbox_common` for the wire protocol, plus separate daemon and guest binaries. - `learning_mode_core` is the cross-platform learning-mode denial model and output layer. It owns denial types, summaries, analyzer abstractions, plain-JSON document emission, and the serializable output-pointer type, and must not depend on any `backends/*` crate. -- `learning_mode_windows` (`backends/learning_mode/windows`) is a Windows-only backend support crate for the AppInfo-brokered Learning Mode APIs in `processmodel.dll`. It runtime-resolves the Learning Mode trace and process security-environment exports, owns their typed handle/lifecycle wrappers, decodes sealed ETL traces through `learning_mode_core`, and depends on `wxc_common` plus `learning_mode_core`; runner integration consumes it from the AppContainer backend layer. +- `learning_mode_windows` (`backends/learning_mode/windows`) is a Windows-only backend support crate for the AppInfo-brokered Learning Mode APIs in `processmodel.dll`. It runtime-resolves the Learning Mode trace and process security-environment exports, owns their typed handle/lifecycle wrappers, decodes sealed ETL traces through `learning_mode_core`, and depends on `wxc_common` plus `learning_mode_core`; runner integration consumes it from the AppContainer backend layer. The trace contract is `HRESULT Start` + retryable `HRESULT Stop` + infallible `Close`: `Stop` never consumes the trace handle, and every started trace must be closed exactly once (closing without stopping is the early-exit discard path). The process security-environment contract is `HRESULT Create` + infallible by-value `Close` and consumes a PSEC 1.0 FlatBuffer, not the legacy SBOX buffer; generated PSEC bindings live in `core/generated/process_security_environment_specification`. - `wxc`, `lxc`, and `mxc_darwin` are thin binary crates (`wxc-exec` / `lxc-exec` / `mxc-exec-mac`) that wire up CLI args (`clap`), load/validate config, handle maintenance modes (`--probe`, `--delete`, `--setup-*`, `--audit`), and **delegate all backend dispatch to `mxc_engine`**. They contain no `match request.containment` of their own. `wxc-exec` additionally owns the Windows Ctrl-C / DACL-cleanup / `--audit` PLM-trace / telemetry orchestration around the engine call. - `mxc_engine` is the **single execution engine** — the one home for "given an `ExecutionRequest`, run it". It owns: run-to-completion backend selection (`run` / `resolve_runner`, covering **all** backends, incl. the Windows ProcessContainer BaseContainer/AppContainer BFS/DACL fallback tiers via `appcontainer_common::dispatcher::dispatch_with_fallback`, and every experimental backend, feature-gated); streaming (`spawn` → `Box`); state-aware lifecycle dispatch (`run_state_aware`, including Windows Sandbox and IsolationSession); host probing (`platform_support` / `PlatformSupport`); and config building (`build_request`, `SandboxPolicy` + sections, `available_tools_policy`/`user_profile_policy`/`temporary_files_policy`). It depends on the backend crates (cfg-split: appcontainer/windows_sandbox lifecycle/isolation_session/wslc/nanvix on Windows, bubblewrap/lxc/nanvix on Linux, seatbelt on macOS) so it can't live in `wxc_common`. Both the executor binaries and `mxc-sdk` call into it. `ResolvedRunner` carries the boxed runner plus (Windows only) the optional `DaclManager` guard, so `wxc-exec` can park the guard for its signal handler. - `mxc-sdk` is the **public Rust SDK** — a thin facade over `mxc_engine`. Build a `SandboxRequest` with `build_request`, then either `run(request)` (run-to-completion; returns an `Output` with the `WaitOutcome`, captured `stdout`/`stderr`, warnings, and optional structured output metadata) or `spawn_sandbox(request)` (returns a `Sandbox` handle for live bidirectional stdio — `take_stdin`/`take_stdout`/`take_stderr`, `kill()`, `wait()` returning a `WaitOutcome` (`Exited(i32)` / `TimedOut`) as `io::Result`, `output_metadata()` after terminal completion, or `wait_with_output()`). It re-exports the engine's config-building surface (`build_request`, `mxc_sdk::policy::{SandboxPolicy sections}`, discovery helpers) and `platform_support`; `mod sandbox` (wrapping the engine's `SandboxProcess` in `Sandbox`) is its only local module. No pty is ever allocated. Streaming supports Seatbelt (macOS), Bubblewrap (Linux), and Windows ProcessContainer (AppContainer + BaseContainer); other backends return `ErrorCode::UnsupportedContainment`. diff --git a/docs/learning-mode/capabilities.md b/docs/learning-mode/capabilities.md index 9f9645b34..95a595e5f 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -121,6 +121,11 @@ ungranted access is handled while it is recorded: > build exposing the BaseContainer security-environment and Learning Mode APIs. > It is not supported by the AppContainer fallback tiers; unsupported hosts > return `backend_unavailable`. +> +> `captureDenials` cannot be combined with `processContainer.leastPrivilege`; +> the Windows process security-environment API used for capture does not expose +> an LPAC token option, so MXC rejects that combination rather than silently +> weakening the requested policy. - `mode: "block"` (default) maps onto `learningModeLogging` (deny-and-record) — the app / user-configurable flow. diff --git a/docs/schema.md b/docs/schema.md index a43b29e75..fb74d28d0 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -72,6 +72,7 @@ production configs and the dev schema when working on experimental features: } // dir must already exist; a unique per-run id is stamped // into the stem (denials..json) and the actual // path printed on stderr. Omit outputPath for a managed temp file. + // captureDenials cannot be combined with leastPrivilege. }, "lxc": { // LXC-specific diff --git a/external/windows-sdk/ProcessSecurityEnvironment.fbs b/external/windows-sdk/ProcessSecurityEnvironment.fbs new file mode 100644 index 000000000..b094d6ab6 --- /dev/null +++ b/external/windows-sdk/ProcessSecurityEnvironment.fbs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +namespace ProcessSecurityEnvironmentLayout; + +struct SchemaVersion { + major:uint16; + minor:uint16; +} + +table ProcessSecurityEnvironment { + version:SchemaVersion (required); + capabilities:string; + disallow_win32k_system_calls:bool = false; + ui_restrictions:uint64 = 0; + fs_read_write:[string]; + fs_read_only:[string]; + fs_deny:[string]; + network_policy:NetworkPolicy; +} + +table ProxyInfo { + url:string; +} + +enum FilterAction : byte { deny, allow } +enum IpProtocol : byte { any, tcp, udp, icmpv4, icmpv6 } + +table IpSubnet { + address:string; + prefix_length:ubyte = 0; +} + +table DestinationRule { + subnet:IpSubnet; + except:[IpSubnet]; +} + +table PortRule { + protocol:IpProtocol = any; + port:uint16 = 0; + end_port:uint16 = 0; +} + +table EndpointRule { + destinations:[DestinationRule]; + ports:[PortRule]; +} + +table EndpointPolicy { + default_action:FilterAction = deny; + allow:[EndpointRule]; + deny:[EndpointRule]; +} + +table NetworkPolicy { + proxy:ProxyInfo; + egress:EndpointPolicy; + allowed_appcontainer_peer:string; +} + +root_type ProcessSecurityEnvironment; +file_identifier "PSEC"; diff --git a/src/Cargo.lock b/src/Cargo.lock index 15e7a1b9b..c827cfcac 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -90,6 +90,7 @@ dependencies = [ "getrandom 0.2.17", "learning_mode_core", "learning_mode_windows", + "process_security_environment_spec", "sandbox_spec", "serde", "serde_json", @@ -1281,7 +1282,7 @@ version = "0.7.0" dependencies = [ "flatbuffers", "learning_mode_core", - "sandbox_spec", + "process_security_environment_spec", "thiserror", "windows", "windows-core", @@ -1777,6 +1778,13 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process_security_environment_spec" +version = "0.7.0" +dependencies = [ + "flatbuffers", +] + [[package]] name = "quick-xml" version = "0.41.0" diff --git a/src/Cargo.toml b/src/Cargo.toml index 4a9f7e2ce..2cca1a38a 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -11,6 +11,7 @@ members = [ "core/mxc_build_common", "host/plm", "core/generated/base_container_specification", + "core/generated/process_security_environment_specification", "backends/appcontainer/common", "backends/windows_sandbox/daemon", "backends/windows_sandbox/guest", @@ -92,7 +93,7 @@ windows = { version = "0.62", features = [ "Win32_System_Time", "Win32_System_SystemServices", "Win32_System_SystemInformation", - "Win32_System_JobObjects", + "Win32_System_JobObjects", ] } windows-core = "0.62" serde = { version = "1", features = ["derive"] } @@ -129,6 +130,7 @@ isolation_session_bindings = { path = "backends/isolation_session/bindings" } mxc_pty = { path = "core/mxc_pty" } flatbuffers = "25" sandbox_spec = { path = "core/generated/base_container_specification" } +process_security_environment_spec = { path = "core/generated/process_security_environment_specification" } mxc_telemetry = { path = "mxc_telemetry" } widestring = "1" url = "2" diff --git a/src/backends/appcontainer/common/Cargo.toml b/src/backends/appcontainer/common/Cargo.toml index 4da831a40..835e36794 100644 --- a/src/backends/appcontainer/common/Cargo.toml +++ b/src/backends/appcontainer/common/Cargo.toml @@ -24,6 +24,7 @@ windows = { workspace = true } windows-core = { workspace = true } flatbuffers = { workspace = true } sandbox_spec = { workspace = true } +process_security_environment_spec = { workspace = true } widestring = { workspace = true } winreg = { workspace = true } learning_mode_windows = { workspace = true } diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index 58ee4cf4f..56c4f8fb9 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -48,6 +48,12 @@ use crate::launch_diagnostics::{ }; use crate::proxy_coordinator::ProxyCoordinator; use crate::sandbox_tracking::{self, TrackingEntry}; +use process_security_environment_spec::process_security_environment_layout::{ + finish_process_security_environment_buffer, NetworkPolicy as PsecNetworkPolicy, + NetworkPolicyArgs as PsecNetworkPolicyArgs, ProcessSecurityEnvironment, + ProcessSecurityEnvironmentArgs, ProxyInfo as PsecProxyInfo, ProxyInfoArgs as PsecProxyInfoArgs, + SchemaVersion, +}; use sandbox_spec::base_container_layout::{ finish_sandbox_spec_buffer, proxy_info, proxy_infoArgs, IntegrityLevel, NetworkPolicy as FbsNetworkPolicy, NetworkPolicyArgs, SandboxSpec, SandboxSpecArgs, @@ -95,6 +101,21 @@ fn encode_env_block(env_vars: &[String]) -> Vec { block } +fn create_string_vector<'a>( + builder: &mut flatbuffers::FlatBufferBuilder<'a>, + values: &'a [String], +) -> Option>>> +{ + if values.is_empty() { + return None; + } + let offsets: Vec<_> = values + .iter() + .map(|value| builder.create_string(value)) + .collect(); + Some(builder.create_vector(&offsets)) +} + /// Function pointer type matching `Experimental_CreateProcessInSandbox` from processmodel.dll. type PfnCreateProcessInSandbox = unsafe extern "system" fn( application_name: *const u16, @@ -206,9 +227,6 @@ const SANDBOX_CAP_CREATE_PROCESS_IN_SANDBOX: u64 = 0x0000_0000_0000_0001; const SANDBOX_CAP_FS_DENY: u64 = 0x0000_0000_0000_0002; const CAPTURE_API_AVAILABLE_LOG: &str = "captureDenials: learning-mode trace API available (processmodel.dll)"; -const CAPTURE_SECURITY_ENVIRONMENT_CLEANUP_DEFERRED_REASON: &str = - "capture teardown failed and the process security environment may still be live"; -const CLOSE_PROCESS_SECURITY_ENVIRONMENT_API: &str = "CloseProcessSecurityEnvironment"; const CREATE_PROCESS_IN_SANDBOX_API: &str = "Experimental_CreateProcessInSandbox"; const CREATE_PROCESS_IN_SECURITY_ENVIRONMENT_API: &str = "CreateProcessW(PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT)"; @@ -223,41 +241,14 @@ fn learning_mode_api_not_implemented(error: &learning_mode_windows::LearningMode match error { learning_mode_windows::LearningModeError::DllLoad(_) | learning_mode_windows::LearningModeError::ExportMissing { .. } => true, + learning_mode_windows::LearningModeError::HResultCall { code, .. } => *code == E_NOTIMPL.0, learning_mode_windows::LearningModeError::ApiCall { code, .. } => { - is_api_not_implemented(*code) || *code == ERROR_NOT_SUPPORTED.0 - } - learning_mode_windows::LearningModeError::CleanupFailed { primary, .. } => { - learning_mode_api_not_implemented(primary) + is_api_not_implemented(*code) } _ => false, } } -fn learning_mode_cleanup_failed(error: &learning_mode_windows::LearningModeError) -> bool { - match error { - learning_mode_windows::LearningModeError::ApiCall { function, .. } => { - *function == CLOSE_PROCESS_SECURITY_ENVIRONMENT_API - } - learning_mode_windows::LearningModeError::CleanupFailed { primary, cleanup } => { - learning_mode_cleanup_failed(primary) || learning_mode_cleanup_failed(cleanup) - } - _ => false, - } -} - -fn combine_capture_operation_and_cleanup_errors( - primary: learning_mode_windows::LearningModeError, - cleanup: Result<(), learning_mode_windows::LearningModeError>, -) -> learning_mode_windows::LearningModeError { - match cleanup { - Ok(()) => primary, - Err(cleanup) => learning_mode_windows::LearningModeError::CleanupFailed { - primary: Box::new(primary), - cleanup: Box::new(cleanup), - }, - } -} - trait CaptureSessionOps { fn environment(&self) -> HANDLE; fn finish( @@ -358,27 +349,17 @@ impl BaseContainerRunner { fn cleanup_capture_prelaunch_failure( &mut self, - cleanup_error: Option<&learning_mode_windows::LearningModeError>, request: &ExecutionRequest, sid_string: &str, logger: &mut Logger, ) { // This cannot be deferred to BaseContainerRunner::drop: the runner may - // outlive a failed spawn, and the exact error determines whether - // profile deletion is safe or recovery tracking must be retained. + // outlive a failed spawn. if request.lifecycle.destroy_on_exit { - if cleanup_error.is_some_and(learning_mode_cleanup_failed) { - sandbox_tracking::mark_cleanup_deferred( - sid_string, - CAPTURE_SECURITY_ENVIRONMENT_CLEANUP_DEFERRED_REASON, - logger, - ); - } else { - // CaptureSession::begin receives the sandbox specification, - // not the later process identity. Once its environment is - // closed, only the pre-launch tracking entry needs removal. - sandbox_tracking::remove_tracking_entry(sid_string, logger); - } + // CaptureSession::begin receives the sandbox specification, not + // the later process identity. Its infallible environment close + // leaves only the pre-launch tracking entry to remove. + sandbox_tracking::remove_tracking_entry(sid_string, logger); sandbox_tracking::unregister_ctrl_c_cleanup(); } self.proxy_coordinator.stop(logger); @@ -551,21 +532,7 @@ impl BaseContainerRunner { let version = builder.create_string(SANDBOX_SPEC_VERSION); - // Match legacy AppContainer behaviour: when network enforcement uses - // capabilities and the default policy is Allow, ensure internetClient - // is present so the sandboxed process has network access. - let mut caps = request.policy.capabilities.clone(); - let use_caps_for_network = matches!( - request.policy.network_enforcement_mode, - NetworkEnforcementMode::Capabilities | NetworkEnforcementMode::Both - ); - if use_caps_for_network - && request.policy.default_network_policy == NetworkPolicy::Allow - && !caps.iter().any(|c| c == "internetClient") - { - caps.push("internetClient".to_string()); - } - + let caps = Self::effective_capabilities(request); let capabilities = if caps.is_empty() { None } else { @@ -666,6 +633,86 @@ impl BaseContainerRunner { builder.finished_data().to_vec() } + fn effective_capabilities(request: &ExecutionRequest) -> Vec { + // Match legacy AppContainer behaviour: when network enforcement uses + // capabilities and the default policy is Allow, ensure internetClient + // is present so the sandboxed process has network access. + let mut caps = request.policy.capabilities.clone(); + let use_caps_for_network = matches!( + request.policy.network_enforcement_mode, + NetworkEnforcementMode::Capabilities | NetworkEnforcementMode::Both + ); + if use_caps_for_network + && request.policy.default_network_policy == NetworkPolicy::Allow + && !caps.iter().any(|c| c == "internetClient") + { + caps.push("internetClient".to_string()); + } + caps + } + + /// Build the PSEC 1.0 FlatBuffer consumed by + /// `CreateProcessSecurityEnvironment`. + fn build_process_security_environment_spec(request: &ExecutionRequest) -> Vec { + let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024); + let version = SchemaVersion::new(1, 0); + + let caps = Self::effective_capabilities(request); + let capabilities = if caps.is_empty() { + None + } else { + Some(builder.create_string(&caps.join(","))) + }; + + let fs_read_write = create_string_vector(&mut builder, &request.policy.readwrite_paths); + let fs_read_only = create_string_vector(&mut builder, &request.policy.readonly_paths); + let fs_deny = create_string_vector(&mut builder, &request.policy.denied_paths); + + let network_policy = if request.policy.network_proxy.is_enabled() { + let proxy = request + .policy + .network_proxy + .address + .as_ref() + .map(|address| { + let url = builder.create_string(&address.to_url()); + PsecProxyInfo::create(&mut builder, &PsecProxyInfoArgs { url: Some(url) }) + }); + Some(PsecNetworkPolicy::create( + &mut builder, + &PsecNetworkPolicyArgs { + proxy, + ..Default::default() + }, + )) + } else { + None + }; + + let ui_restrictions = crate::job_object::to_job_object_uilimit_mask( + &wxc_common::ui_policy::resolve_ui_restrictions( + &request.policy.ui, + &request.policy.base_process_ui, + ), + ) as u64; + + let spec = ProcessSecurityEnvironment::create( + &mut builder, + &ProcessSecurityEnvironmentArgs { + version: Some(&version), + capabilities, + disallow_win32k_system_calls: request.policy.ui.disable, + ui_restrictions, + fs_read_write, + fs_read_only, + fs_deny, + network_policy, + }, + ); + finish_process_security_environment_buffer(&mut builder, spec); + builder.finished_data().to_vec() + } + /// Log the contents of a built sandbox spec FlatBuffer for debug verification. /// /// Reads back token, network, and UI restriction fields from the serialised @@ -895,6 +942,17 @@ impl BaseContainerRunner { let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: captureDenials"); } + let capture_spec_bytes = capture_denials + .as_ref() + .map(|_| Self::build_process_security_environment_spec(&request)); + if let Some(capture_spec) = capture_spec_bytes.as_ref() { + let _ = writeln!( + logger, + "process security environment spec built (PSEC 1.0, {} bytes)", + capture_spec.len() + ); + } + // Resolve two paths for the capture: // * `capture_etl_path` — an always-internal, runner-managed temp `.etl` // that the OS broker seals into. It is decoded then deleted in @@ -1232,9 +1290,12 @@ impl BaseContainerRunner { // discards the trace and closes the environment (no broker leak). let mut capture_session: Option> = None; if capture_denials.is_some() { + let capture_spec = capture_spec_bytes + .as_deref() + .expect("capture spec is initialized with captureDenials"); match self .capture_factory - .begin(&spec_bytes, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE) + .begin(capture_spec, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE) { Ok(session) => { let _ = writeln!( @@ -1251,7 +1312,7 @@ impl BaseContainerRunner { } else { FailurePhase::LaunchFailed }; - self.cleanup_capture_prelaunch_failure(Some(&e), &request, &sid_string, logger); + self.cleanup_capture_prelaunch_failure(&request, &sid_string, logger); return Err(ScriptResponse { exit_code: -1, error_message: msg.clone(), @@ -1282,26 +1343,31 @@ impl BaseContainerRunner { ) { Ok(startup) => startup, Err(primary) => { - let cleanup = capture_session + let cleanup_error = capture_session .take() .map(|session| session.finish(None)) - .unwrap_or(Ok(())); - let error = combine_capture_operation_and_cleanup_errors(primary, cleanup); - let msg = format!( - "captureDenials: failed to attach the process security environment: {error}" + .unwrap_or(Ok(())) + .err(); + let mut msg = format!( + "captureDenials: failed to attach the process security environment: {primary}" + ); + if let Some(cleanup_error) = &cleanup_error { + let _ = write!( + msg, + "; additionally failed to discard the learning-mode trace: {cleanup_error}" ); + } let _ = writeln!(logger, "Error: {msg}"); - let failure_phase = if learning_mode_api_not_implemented(&error) { + let failure_phase = if learning_mode_api_not_implemented(&primary) + || cleanup_error + .as_ref() + .is_some_and(learning_mode_api_not_implemented) + { FailurePhase::BackendUnavailable } else { FailurePhase::LaunchFailed }; - self.cleanup_capture_prelaunch_failure( - Some(&error), - &request, - &sid_string, - logger, - ); + self.cleanup_capture_prelaunch_failure(&request, &sid_string, logger); return Err(ScriptResponse { exit_code: -1, error_message: msg.clone(), @@ -1369,12 +1435,7 @@ impl BaseContainerRunner { .take() .and_then(|session| session.finish(None).err()); if capture_denials.is_some() { - self.cleanup_capture_prelaunch_failure( - capture_cleanup_error.as_ref(), - &request, - &sid_string, - logger, - ); + self.cleanup_capture_prelaunch_failure(&request, &sid_string, logger); } else if request.lifecycle.destroy_on_exit { // The OS may have created the AppContainer profile before // failing, so run the same cleanup logic used on normal exit. @@ -1488,12 +1549,7 @@ impl BaseContainerRunner { .take() .and_then(|session| session.finish(None).err()); if capture_denials.is_some() { - self.cleanup_capture_prelaunch_failure( - capture_cleanup_error.as_ref(), - &request, - &sid_string, - logger, - ); + self.cleanup_capture_prelaunch_failure(&request, &sid_string, logger); } else if request.lifecycle.destroy_on_exit { run_sandbox_cleanup( &identity, @@ -1600,6 +1656,13 @@ struct BaseChild { impl SandboxBackend for BaseContainerRunner { fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { + if request.policy.capture_denials.is_some() && request.policy.least_privilege_mode { + return Err(ScriptResponse::error( + "processContainer.captureDenials cannot be combined with \ + processContainer.leastPrivilege because the Windows process \ + security-environment contract does not support LPAC tokens", + )); + } // deniedPaths reaches the OS via the SandboxSpec `fs_deny` field, honored // only when the OS advertises SANDBOX_CAP_FS_DENY. The dispatcher only // routes deny here when supported; fail closed for direct callers. @@ -1750,7 +1813,6 @@ impl BaseContainerSandboxProcess { // deliverable that consuming apps read, delete the temp, and retain // structured metadata for the caller. Any seal/decode/write failure is // returned through `wait()`. - let mut defer_capture_cleanup = false; let capture_result = if let Some(session) = self.capture_session.take() { let etl_path = self.capture_etl_path.take(); let output_path = self.capture_output_path.take(); @@ -1771,18 +1833,15 @@ impl BaseContainerSandboxProcess { .unwrap_or(Ok(())), ), }, - Err(error) => { - defer_capture_cleanup = learning_mode_cleanup_failed(&error); - combine_capture_and_cleanup_results( - Err(std::io::Error::other(format!( - "captureDenials failed to finalize the denial capture: {error}" - ))), - etl_path - .as_deref() - .map(remove_internal_capture_file) - .unwrap_or(Ok(())), - ) - } + Err(error) => combine_capture_and_cleanup_results( + Err(std::io::Error::other(format!( + "captureDenials failed to finalize the denial capture: {error}" + ))), + etl_path + .as_deref() + .map(remove_internal_capture_file) + .unwrap_or(Ok(())), + ), }; if let Ok(Some(metadata)) = &result { self.output_metadata = Some(SandboxOutputMetadata { @@ -1795,20 +1854,12 @@ impl BaseContainerSandboxProcess { }; if self.destroy_on_exit { - if defer_capture_cleanup { - sandbox_tracking::mark_cleanup_deferred( - &self.sid_string, - CAPTURE_SECURITY_ENVIRONMENT_CLEANUP_DEFERRED_REASON, - &mut logger, - ); - } else { - run_sandbox_cleanup( - &self.identity, - &self.sid_string, - self.proxy_enabled, - &mut logger, - ); - } + run_sandbox_cleanup( + &self.identity, + &self.sid_string, + self.proxy_enabled, + &mut logger, + ); sandbox_tracking::unregister_ctrl_c_cleanup(); } self.proxy_coordinator.stop(&mut logger); @@ -2169,13 +2220,14 @@ mod tests { use learning_mode_core::{ AccessType, AnalysisResult, AnalyzeError, DeniedResource, ResourceType, }; + use process_security_environment_spec::process_security_environment_layout as psec_layout; use sandbox_spec::base_container_layout; use std::sync::atomic::{AtomicUsize, Ordering}; use wxc_common::models::{ClipboardPolicy, ProxyConfig, UiPolicy}; use wxc_common::ui_policy::EffectiveUiRestrictions; struct FakeCaptureSession { - finish_error: Option<(&'static str, u32)>, + finish_error: Option<(&'static str, i32)>, finish_calls: Arc, } @@ -2191,7 +2243,7 @@ mod tests { self.finish_calls.fetch_add(1, Ordering::SeqCst); match self.finish_error { Some((function, code)) => { - Err(learning_mode_windows::LearningModeError::ApiCall { function, code }) + Err(learning_mode_windows::LearningModeError::HResultCall { function, code }) } None => Ok(()), } @@ -2199,8 +2251,8 @@ mod tests { } struct FakeCaptureFactory { - begin_error: Option<(&'static str, u32)>, - finish_error: Option<(&'static str, u32)>, + begin_error: Option<(&'static str, i32)>, + finish_error: Option<(&'static str, i32)>, begin_calls: AtomicUsize, finish_calls: Arc, } @@ -2213,7 +2265,10 @@ mod tests { ) -> Result, learning_mode_windows::LearningModeError> { self.begin_calls.fetch_add(1, Ordering::SeqCst); if let Some((function, code)) = self.begin_error { - return Err(learning_mode_windows::LearningModeError::ApiCall { function, code }); + return Err(learning_mode_windows::LearningModeError::HResultCall { + function, + code, + }); } Ok(Box::new(FakeCaptureSession { finish_error: self.finish_error, @@ -2429,21 +2484,15 @@ mod tests { fn learning_mode_api_not_implemented_checks_primary_failure() { use learning_mode_windows::LearningModeError; - let disabled = LearningModeError::CleanupFailed { - primary: Box::new(LearningModeError::ApiCall { - function: "CreateProcessSecurityEnvironment", - code: ERROR_CALL_NOT_IMPLEMENTED.0, - }), - cleanup: Box::new(LearningModeError::ApiCall { - function: "CloseProcessSecurityEnvironment", - code: 87, - }), + let disabled = LearningModeError::HResultCall { + function: "StartLearningModeTrace", + code: E_NOTIMPL.0, }; assert!(learning_mode_api_not_implemented(&disabled)); - let ordinary = LearningModeError::ApiCall { + let ordinary = LearningModeError::HResultCall { function: "StartLearningModeTrace", - code: 87, + code: windows::Win32::Foundation::E_INVALIDARG.0, }; assert!(!learning_mode_api_not_implemented(&ordinary)); @@ -2459,62 +2508,10 @@ mod tests { )); } - #[test] - fn learning_mode_cleanup_failure_is_detected() { - use learning_mode_windows::LearningModeError; - - let error = LearningModeError::CleanupFailed { - primary: Box::new(LearningModeError::ApiCall { - function: "StartLearningModeTrace", - code: 87, - }), - cleanup: Box::new(LearningModeError::ApiCall { - function: "CloseProcessSecurityEnvironment", - code: 5, - }), - }; - assert!(learning_mode_cleanup_failed(&error)); - - let close_only = LearningModeError::ApiCall { - function: CLOSE_PROCESS_SECURITY_ENVIRONMENT_API, - code: 5, - }; - assert!(learning_mode_cleanup_failed(&close_only)); - - let ordinary = LearningModeError::ApiCall { - function: "StartLearningModeTrace", - code: 87, - }; - assert!(!learning_mode_cleanup_failed(&ordinary)); - } - - #[test] - fn prelaunch_failure_preserves_capture_cleanup_error() { - use learning_mode_windows::LearningModeError; - - let error = combine_capture_operation_and_cleanup_errors( - LearningModeError::ApiCall { - function: "UpdateProcThreadAttribute(SecurityEnvironment)", - code: 87, - }, - Err(LearningModeError::ApiCall { - function: CLOSE_PROCESS_SECURITY_ENVIRONMENT_API, - code: 5, - }), - ); - - assert!(matches!(error, LearningModeError::CleanupFailed { .. })); - assert!(learning_mode_cleanup_failed(&error)); - assert!(error.to_string().contains("UpdateProcThreadAttribute")); - assert!(error - .to_string() - .contains(CLOSE_PROCESS_SECURITY_ENVIRONMENT_API)); - } - #[test] fn capture_factory_injects_begin_failure() { let factory = Arc::new(FakeCaptureFactory { - begin_error: Some(("StartLearningModeTrace", 5)), + begin_error: Some(("StartLearningModeTrace", windows::Win32::Foundation::E_FAIL.0)), finish_error: None, begin_calls: AtomicUsize::new(0), finish_calls: Arc::new(AtomicUsize::new(0)), @@ -2538,7 +2535,7 @@ mod tests { fn capture_factory_injects_finish_failure_once() { let factory = Arc::new(FakeCaptureFactory { begin_error: None, - finish_error: Some((CLOSE_PROCESS_SECURITY_ENVIRONMENT_API, 5)), + finish_error: Some(("StopLearningModeTrace", windows::Win32::Foundation::E_FAIL.0)), begin_calls: AtomicUsize::new(0), finish_calls: Arc::new(AtomicUsize::new(0)), }); @@ -2550,7 +2547,13 @@ mod tests { let error = session.finish(None).expect_err("fake finish must fail"); - assert!(learning_mode_cleanup_failed(&error)); + assert!(matches!( + error, + learning_mode_windows::LearningModeError::HResultCall { + function: "StopLearningModeTrace", + .. + } + )); assert_eq!(factory.begin_calls.load(Ordering::SeqCst), 1); assert_eq!(factory.finish_calls.load(Ordering::SeqCst), 1); } @@ -2637,6 +2640,46 @@ mod tests { assert!(spec.network_policy().is_none()); } + #[test] + fn build_process_security_environment_spec_produces_valid_psec() { + let mut request = ExecutionRequest::default(); + request.policy.capabilities = vec!["internetClient".into(), "registryRead".into()]; + request.policy.readwrite_paths = vec!["C:\\temp".into()]; + request.policy.readonly_paths = vec!["C:\\Windows".into()]; + request.policy.denied_paths = vec!["C:\\secret".into()]; + request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + + let bytes = BaseContainerRunner::build_process_security_environment_spec(&request); + + assert!(psec_layout::process_security_environment_buffer_has_identifier(&bytes)); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + let version = spec.version(); + assert_eq!(version.major(), 1); + assert_eq!(version.minor(), 0); + assert_eq!(spec.capabilities(), Some("internetClient,registryRead")); + assert_eq!( + spec.fs_read_write().unwrap().iter().collect::>(), + vec!["C:\\temp"] + ); + assert_eq!( + spec.fs_read_only().unwrap().iter().collect::>(), + vec!["C:\\Windows"] + ); + assert_eq!( + spec.fs_deny().unwrap().iter().collect::>(), + vec!["C:\\secret"] + ); + assert_eq!( + spec.network_policy() + .and_then(|policy| policy.proxy()) + .and_then(|proxy| proxy.url()), + Some("http://127.0.0.1:8080") + ); + } + #[test] fn build_sandbox_spec_empty_policy() { // Default network policy is Block — no internetClient auto-add. @@ -2862,4 +2905,18 @@ mod tests { assert!(runner.validate(&request).is_ok()); } } + + #[test] + fn validate_runner_rejects_capture_denials_with_least_privilege() { + let runner = BaseContainerRunner::new(); + let mut request = ExecutionRequest::default(); + request.policy.capture_denials = Some(Default::default()); + request.policy.least_privilege_mode = true; + + let error = runner + .validate(&request) + .expect_err("PSEC cannot represent leastPrivilege"); + + assert!(error.error_message.contains("leastPrivilege")); + } } diff --git a/src/backends/learning_mode/windows/Cargo.toml b/src/backends/learning_mode/windows/Cargo.toml index 515448a39..36a742bab 100644 --- a/src/backends/learning_mode/windows/Cargo.toml +++ b/src/backends/learning_mode/windows/Cargo.toml @@ -14,5 +14,5 @@ windows = { workspace = true } windows-core = { workspace = true } [target.'cfg(target_os = "windows")'.dev-dependencies] -sandbox_spec = { workspace = true } flatbuffers = { workspace = true } +process_security_environment_spec = { workspace = true } diff --git a/src/backends/learning_mode/windows/examples/lm_capture.rs b/src/backends/learning_mode/windows/examples/lm_capture.rs index 1a925481d..e433ce70f 100644 --- a/src/backends/learning_mode/windows/examples/lm_capture.rs +++ b/src/backends/learning_mode/windows/examples/lm_capture.rs @@ -13,7 +13,8 @@ //! `PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT` and launch `cmd.exe` with //! `CreateProcessW`, //! 4. wait for it to exit, -//! 5. [`CaptureSession::finish`] — seal the ETL to a temp path + close the environment, +//! 5. [`CaptureSession::finish`] — stop and deliver the ETL, close the trace, then close +//! the environment, //! 6. assert the ETL file was produced (non-empty). //! //! Run on a feature-enabled Windows build (elevated): @@ -40,13 +41,13 @@ fn main() { mod windows_impl { use std::path::PathBuf; - use flatbuffers::FlatBufferBuilder; use learning_mode_windows::{ CaptureSession, LearningModeApi, SecurityEnvironmentApi, SecurityEnvironmentStartupInfo, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE, }; - use sandbox_spec::base_container_layout::{ - finish_sandbox_spec_buffer, SandboxSpec, SandboxSpecArgs, + use process_security_environment_spec::process_security_environment_layout::{ + finish_process_security_environment_buffer, ProcessSecurityEnvironment, + ProcessSecurityEnvironmentArgs, SchemaVersion, }; use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0}; use windows::Win32::System::Threading::{ @@ -55,26 +56,22 @@ mod windows_impl { }; use windows_core::{PCWSTR, PWSTR}; - /// Matches the schema version BaseContainer embeds in every spec payload. - const SANDBOX_SPEC_VERSION: &str = "0.1.0"; - - /// Build a minimal FlatBuffer `SandboxSpec` carrying the learning-mode capability. + /// Build a minimal PSEC 1.0 FlatBuffer carrying the learning-mode capability. fn build_sandbox_spec() -> Vec { - let mut builder = FlatBufferBuilder::with_capacity(256); - let version = builder.create_string(SANDBOX_SPEC_VERSION); + let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(256); + let version = SchemaVersion::new(1, 0); // `permissiveLearningMode` is the capability the SandboxEngine functest uses to // exercise the learning-mode trace; it reliably drives recorded events. let capabilities = builder.create_string("permissiveLearningMode"); - let spec = SandboxSpec::create( + let spec = ProcessSecurityEnvironment::create( &mut builder, - &SandboxSpecArgs { - version: Some(version), - app_container: true, + &ProcessSecurityEnvironmentArgs { + version: Some(&version), capabilities: Some(capabilities), ..Default::default() }, ); - finish_sandbox_spec_buffer(&mut builder, spec); + finish_process_security_environment_buffer(&mut builder, spec); builder.finished_data().to_vec() } @@ -128,7 +125,7 @@ mod windows_impl { } Err(e) => { eprintln!("launch failed: {e}"); - // `session` drops here → trace discarded + environment closed. + // `session` drops here → trace closed/discarded + environment closed. return 1; } }; @@ -139,7 +136,7 @@ mod windows_impl { eprintln!("CaptureSession::finish failed: {e}"); return 1; } - println!("CaptureSession::finish OK — trace sealed, environment closed"); + println!("CaptureSession::finish OK — trace delivered and closed, environment closed"); match std::fs::metadata(&etl_path) { Ok(meta) => { diff --git a/src/backends/learning_mode/windows/examples/lm_probe.rs b/src/backends/learning_mode/windows/examples/lm_probe.rs index 1dc3b8e93..c76a019c8 100644 --- a/src/backends/learning_mode/windows/examples/lm_probe.rs +++ b/src/backends/learning_mode/windows/examples/lm_probe.rs @@ -4,9 +4,9 @@ //! Manual validation probe for the Learning Mode trace + security-environment API. //! //! Prints whether `processmodel.dll` on this machine exposes the Learning Mode trace -//! exports (`StartLearningModeTrace` / `StopLearningModeTrace`) and the 2-phase -//! security-environment exports (`CreateProcessSecurityEnvironment` / -//! `CloseProcessSecurityEnvironment`), +//! exports (`StartLearningModeTrace` / `StopLearningModeTrace` / +//! `CloseLearningModeTrace`) and the 2-phase security-environment exports +//! (`CreateProcessSecurityEnvironment` / `CloseProcessSecurityEnvironment`), //! reporting the exact resolved name for each (plain vs `Experimental_`). Intended to //! be run on a feature-enabled Windows build to confirm the runtime FFI resolves //! against the real API. diff --git a/src/backends/learning_mode/windows/src/ffi.rs b/src/backends/learning_mode/windows/src/ffi.rs index 3b32288de..3f907468e 100644 --- a/src/backends/learning_mode/windows/src/ffi.rs +++ b/src/backends/learning_mode/windows/src/ffi.rs @@ -3,7 +3,7 @@ //! Windows runtime FFI for the `processmodel.dll` Learning Mode trace exports. //! -//! The two exports are resolved once via `LoadLibraryExW(LOAD_LIBRARY_SEARCH_SYSTEM32)` +//! The three exports are resolved once via `LoadLibraryExW(LOAD_LIBRARY_SEARCH_SYSTEM32)` //! and `GetProcAddress`. As with the sibling `Experimental_CreateProcessInSandbox` //! adapter, `processmodel.dll` is intentionally never freed: it is a system DLL that //! stays resident for the process lifetime, so the module handle is used only to @@ -16,7 +16,7 @@ use windows::Win32::Foundation::{GetLastError, HANDLE, HMODULE}; use windows::Win32::System::LibraryLoader::{ GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32, }; -use windows_core::{PCSTR, PCWSTR}; +use windows_core::{HRESULT, PCSTR, PCWSTR}; use wxc_common::string_util; use crate::LearningModeError; @@ -24,38 +24,80 @@ use crate::LearningModeError; /// System DLL that hosts the flat Learning Mode trace exports. const PROCESSMODEL_DLL: &str = "processmodel.dll"; -/// `BOOL StartLearningModeTrace(HANDLE hProcessSecurityEnvironment, HLEARNINGMODE_TRACE* pphTrace)`. +/// `HRESULT StartLearningModeTrace(HANDLE securityEnvironment, HLEARNINGMODE_TRACE* trace)`. /// /// `HLEARNINGMODE_TRACE` is a `typedef HANDLE`; the export surfaces it through the -/// out-parameter. A zero (`FALSE`) return signals failure (`GetLastError`). -type PfnStartLearningModeTrace = - unsafe extern "system" fn(process_security_environment: HANDLE, trace_out: *mut HANDLE) -> i32; +/// out-parameter. +type PfnStartLearningModeTrace = unsafe extern "system" fn( + process_security_environment: HANDLE, + trace_out: *mut HANDLE, +) -> HRESULT; -/// `BOOL StopLearningModeTrace(HLEARNINGMODE_TRACE* pphTrace, LPCWSTR lpOutputPath)`. +/// `HRESULT StopLearningModeTrace(HLEARNINGMODE_TRACE trace, LPCWSTR outputEtlPath)`. /// /// A non-null `output_path` names a file the export opens under the caller's own -/// identity; the broker seals the ETL into it. A null `output_path` discards the -/// trace. `*trace` is set to null on return regardless. +/// identity; the broker seals and copies the ETL into it. A null `output_path` +/// stops without delivery. The handle remains valid so the caller may retry +/// delivery until it closes the trace. type PfnStopLearningModeTrace = - unsafe extern "system" fn(trace: *mut HANDLE, output_path: *const u16) -> i32; + unsafe extern "system" fn(trace: HANDLE, output_path: *const u16) -> HRESULT; + +/// `void CloseLearningModeTrace(HLEARNINGMODE_TRACE trace)`. +type PfnCloseLearningModeTrace = unsafe extern "system" fn(trace: HANDLE); /// Opaque handle to an in-progress Learning Mode trace (`HLEARNINGMODE_TRACE`). /// -/// Obtained from [`LearningModeApi::start_trace`] and consumed by -/// [`LearningModeApi::stop_trace`]. The handle is owned by the AppInfo broker and -/// bound to this process; if the process exits without stopping, the broker discards -/// the trace automatically. -#[derive(Debug)] -pub struct LearningModeTraceHandle(HANDLE); +/// Obtained from [`LearningModeApi::start_trace`]. [`LearningModeApi::stop_trace`] +/// borrows it so delivery can be retried. Dropping or explicitly closing the +/// handle releases all broker state; closing without stopping discards the trace. +pub struct LearningModeTraceHandle { + raw: HANDLE, + close: PfnCloseLearningModeTrace, +} + +impl LearningModeTraceHandle { + fn new(raw: HANDLE, close: PfnCloseLearningModeTrace) -> Self { + Self { raw, close } + } + + /// Close the trace and release all service-managed state. + pub fn close(mut self) { + self.close_inner(); + } + + fn close_inner(&mut self) { + if !self.raw.0.is_null() { + // SAFETY: `raw` was returned by `StartLearningModeTrace`, and + // `close` was resolved from the same processmodel.dll contract. + unsafe { (self.close)(self.raw) }; + self.raw = HANDLE(ptr::null_mut()); + } + } +} + +impl std::fmt::Debug for LearningModeTraceHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("LearningModeTraceHandle") + .field(&self.raw) + .finish() + } +} + +impl Drop for LearningModeTraceHandle { + fn drop(&mut self) { + self.close_inner(); + } +} /// Resolved Learning Mode trace exports from `processmodel.dll`. /// -/// Construct with [`LearningModeApi::load`]. Cloning is cheap (the struct holds two +/// Construct with [`LearningModeApi::load`]. Cloning is cheap (the struct holds three /// function pointers into the resident system DLL). #[derive(Clone, Copy)] pub struct LearningModeApi { start: PfnStartLearningModeTrace, stop: PfnStopLearningModeTrace, + close: PfnCloseLearningModeTrace, } impl std::fmt::Debug for LearningModeApi { @@ -63,6 +105,7 @@ impl std::fmt::Debug for LearningModeApi { f.debug_struct("LearningModeApi") .field("start", &(self.start as *const ())) .field("stop", &(self.stop as *const ())) + .field("close", &(self.close as *const ())) .finish() } } @@ -72,8 +115,9 @@ impl LearningModeApi { /// /// # Errors /// - [`LearningModeError::DllLoad`] if `processmodel.dll` cannot be loaded. - /// - [`LearningModeError::ExportMissing`] if either export is absent (the OS - /// build predates the API or has it gated off). + /// - [`LearningModeError::ExportMissing`] if any export is absent. Requiring + /// `CloseLearningModeTrace` rejects builds that expose the incompatible + /// earlier two-export ABI. pub fn load() -> Result { let dll = string_util::to_wide(PROCESSMODEL_DLL); @@ -89,11 +133,13 @@ impl LearningModeApi { let start_proc = resolve_export(hmodule, c"StartLearningModeTrace")?; let stop_proc = resolve_export(hmodule, c"StopLearningModeTrace")?; + let close_proc = resolve_export(hmodule, c"CloseLearningModeTrace")?; let start: PfnStartLearningModeTrace = std::mem::transmute(start_proc); let stop: PfnStopLearningModeTrace = std::mem::transmute(stop_proc); + let close: PfnCloseLearningModeTrace = std::mem::transmute(close_proc); - Ok(Self { start, stop }) + Ok(Self { start, stop, close }) } } @@ -106,8 +152,7 @@ impl LearningModeApi { /// AppContainer SID server-side. /// /// # Errors - /// [`LearningModeError::ApiCall`] carrying `GetLastError` if the export returns - /// `FALSE`. + /// [`LearningModeError::HResultCall`] if the export returns a failing HRESULT. pub unsafe fn start_trace( &self, security_environment: HANDLE, @@ -116,70 +161,50 @@ impl LearningModeApi { // SAFETY: `self.start` was resolved from `processmodel.dll` and matches the // declared C signature; `trace` is a valid out-pointer. The caller upholds // the validity of `security_environment` per this method's safety contract. - let ok = (self.start)(security_environment, &mut trace); - if ok == 0 { - return Err(LearningModeError::ApiCall { + let result = (self.start)(security_environment, &mut trace); + if result.is_err() { + return Err(LearningModeError::HResultCall { function: "StartLearningModeTrace", - code: last_error(), + code: result.0, }); } - Ok(LearningModeTraceHandle(trace)) + Ok(LearningModeTraceHandle::new(trace, self.close)) } - /// Stop `trace`, sealing the ETL into `output_path`. Passing `None` discards the - /// trace (used for early-exit teardown). + /// Stop `trace`, sealing and copying the ETL into `output_path`. Passing `None` + /// stops without delivery. /// - /// The handle is consumed; the export nulls it internally on return. + /// The handle remains live after success or failure, so callers may retry with + /// the same or a different output path before closing it. /// /// # Errors /// - [`LearningModeError::InvalidInput`] if `output_path` contains an embedded NUL. - /// - [`LearningModeError::ApiCall`] carrying `GetLastError` if the export returns - /// `FALSE`. - /// - [`LearningModeError::CleanupFailed`] if rejecting an invalid path also fails - /// to discard the live trace. + /// - [`LearningModeError::HResultCall`] if the export returns a failing HRESULT. pub fn stop_trace( &self, - trace: LearningModeTraceHandle, + trace: &LearningModeTraceHandle, output_path: Option<&Path>, ) -> Result<(), LearningModeError> { - let wide_path = match encode_output_path(output_path) { - Ok(path) => path, - Err(primary) => return Err(self.discard_trace_after_error(trace, primary)), - }; + let wide_path = encode_output_path(output_path)?; self.stop_trace_encoded(trace, wide_path.as_deref()) } - fn discard_trace_after_error( - &self, - trace: LearningModeTraceHandle, - primary: LearningModeError, - ) -> LearningModeError { - match self.stop_trace_encoded(trace, None) { - Ok(()) => primary, - Err(cleanup) => LearningModeError::CleanupFailed { - primary: Box::new(primary), - cleanup: Box::new(cleanup), - }, - } - } - fn stop_trace_encoded( &self, - trace: LearningModeTraceHandle, + trace: &LearningModeTraceHandle, wide_path: Option<&[u16]>, ) -> Result<(), LearningModeError> { let path_ptr = wide_path.map_or(ptr::null(), |path| path.as_ptr()); - let mut handle = trace.0; // SAFETY: `self.stop` was resolved from `processmodel.dll` and matches the - // declared C signature. `handle` came from a prior `start_trace`, and + // declared C signature. `trace.raw` came from a prior `start_trace`, and // `path_ptr` is either null or points at the null-terminated `wide_path` // buffer, which outlives the call. - let ok = unsafe { (self.stop)(&mut handle, path_ptr) }; - if ok == 0 { - return Err(LearningModeError::ApiCall { + let result = unsafe { (self.stop)(trace.raw, path_ptr) }; + if result.is_err() { + return Err(LearningModeError::HResultCall { function: "StopLearningModeTrace", - code: last_error(), + code: result.0, }); } Ok(()) @@ -230,7 +255,7 @@ fn last_error() -> u32 { unsafe { GetLastError().0 } } -/// Capability probe: `true` only when `processmodel.dll` exposes both Learning Mode +/// Capability probe: `true` only when `processmodel.dll` exposes all three Learning Mode /// trace exports on this machine. #[must_use] pub fn is_learning_mode_api_available() -> bool { @@ -243,6 +268,53 @@ mod tests { use std::ffi::OsString; use std::os::windows::ffi::OsStringExt; use std::path::PathBuf; + use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering}; + use std::sync::Mutex; + use windows::Win32::Foundation::{E_FAIL, S_FALSE, S_OK}; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + static START_RESULT: AtomicI32 = AtomicI32::new(S_OK.0); + static STOP_RESULT: AtomicI32 = AtomicI32::new(S_OK.0); + static STOP_CALLS: AtomicUsize = AtomicUsize::new(0); + static CLOSE_CALLS: AtomicUsize = AtomicUsize::new(0); + + unsafe extern "system" fn fake_start(_: HANDLE, trace_out: *mut HANDLE) -> HRESULT { + let result = HRESULT(START_RESULT.load(Ordering::SeqCst)); + if result.is_ok() { + unsafe { + *trace_out = HANDLE(std::ptr::dangling_mut::()); + } + } + result + } + + unsafe extern "system" fn fake_stop(_: HANDLE, _: *const u16) -> HRESULT { + STOP_CALLS.fetch_add(1, Ordering::SeqCst); + HRESULT(STOP_RESULT.load(Ordering::SeqCst)) + } + + unsafe extern "system" fn fake_close(_: HANDLE) { + CLOSE_CALLS.fetch_add(1, Ordering::SeqCst); + } + + fn fake_api() -> LearningModeApi { + LearningModeApi { + start: fake_start, + stop: fake_stop, + close: fake_close, + } + } + + fn fake_environment() -> HANDLE { + HANDLE(std::ptr::dangling_mut::()) + } + + fn reset_fakes() { + START_RESULT.store(S_OK.0, Ordering::SeqCst); + STOP_RESULT.store(S_OK.0, Ordering::SeqCst); + STOP_CALLS.store(0, Ordering::SeqCst); + CLOSE_CALLS.store(0, Ordering::SeqCst); + } #[test] fn probe_does_not_panic_and_matches_load() { @@ -277,9 +349,15 @@ mod tests { #[test] fn output_path_rejects_embedded_nul() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_fakes(); let path = PathBuf::from(OsString::from_wide(&['a' as u16, 0, 'b' as u16])); + let api = fake_api(); + let trace = unsafe { api.start_trace(fake_environment()).unwrap() }; - let error = encode_output_path(Some(&path)).expect_err("embedded NUL must be rejected"); + let error = api + .stop_trace(&trace, Some(&path)) + .expect_err("embedded NUL must be rejected"); assert!(matches!( error, @@ -288,5 +366,79 @@ mod tests { .. } )); + assert_eq!(STOP_CALLS.load(Ordering::SeqCst), 0); + drop(trace); + assert_eq!(CLOSE_CALLS.load(Ordering::SeqCst), 1); + } + + #[test] + fn successful_hresult_starts_retryable_trace() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_fakes(); + START_RESULT.store(S_FALSE.0, Ordering::SeqCst); + let api = fake_api(); + let trace = unsafe { + api.start_trace(fake_environment()) + .expect("non-failing HRESULT should succeed") + }; + + api.stop_trace(&trace, None).unwrap(); + api.stop_trace(&trace, None).unwrap(); + assert_eq!(STOP_CALLS.load(Ordering::SeqCst), 2); + + trace.close(); + assert_eq!(CLOSE_CALLS.load(Ordering::SeqCst), 1); + } + + #[test] + fn failed_hresult_keeps_trace_live_until_close() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_fakes(); + let api = fake_api(); + let trace = unsafe { api.start_trace(fake_environment()).unwrap() }; + STOP_RESULT.store(E_FAIL.0, Ordering::SeqCst); + + let error = api.stop_trace(&trace, None).unwrap_err(); + assert!(matches!( + error, + LearningModeError::HResultCall { + function: "StopLearningModeTrace", + code + } if code == E_FAIL.0 + )); + + drop(trace); + assert_eq!(CLOSE_CALLS.load(Ordering::SeqCst), 1); + } + + #[test] + fn failed_start_preserves_hresult_and_does_not_close() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_fakes(); + START_RESULT.store(E_FAIL.0, Ordering::SeqCst); + let api = fake_api(); + + let error = unsafe { api.start_trace(fake_environment()).unwrap_err() }; + + assert!(matches!( + error, + LearningModeError::HResultCall { + function: "StartLearningModeTrace", + code + } if code == E_FAIL.0 + )); + assert_eq!(CLOSE_CALLS.load(Ordering::SeqCst), 0); + } + + #[test] + fn explicit_close_is_exactly_once() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_fakes(); + let api = fake_api(); + let trace = unsafe { api.start_trace(fake_environment()).unwrap() }; + + trace.close(); + + assert_eq!(CLOSE_CALLS.load(Ordering::SeqCst), 1); } } diff --git a/src/backends/learning_mode/windows/src/lib.rs b/src/backends/learning_mode/windows/src/lib.rs index 31384dbae..de2480583 100644 --- a/src/backends/learning_mode/windows/src/lib.rs +++ b/src/backends/learning_mode/windows/src/lib.rs @@ -5,19 +5,21 @@ //! **Learning Mode trace API** exported by `processmodel.dll`. //! //! Supported Windows builds expose a privileged, per-client learning-mode -//! ETW trace behind two flat C exports in `processmodel.dll` — the same system DLL +//! ETW trace behind three flat C exports in `processmodel.dll` — the same system DLL //! the BaseContainer backend already loads for `Experimental_CreateProcessInSandbox`: //! //! ```c -//! BOOL StartLearningModeTrace(HANDLE hProcessSecurityEnvironment, HLEARNINGMODE_TRACE* pphTrace); -//! BOOL StopLearningModeTrace (HLEARNINGMODE_TRACE* pphTrace, LPCWSTR lpOutputPath); +//! HRESULT StartLearningModeTrace(HPROCESS_SECURITY_ENVIRONMENT environment, HLEARNINGMODE_TRACE* trace); +//! HRESULT StopLearningModeTrace(HLEARNINGMODE_TRACE trace, PCWSTR outputEtlPath); +//! void CloseLearningModeTrace(HLEARNINGMODE_TRACE trace); //! ``` //! //! The broker collects and filters the trace to the caller's user SID and the -//! sandbox identified by the supplied security-environment handle, then — on stop — -//! writes the sealed ETL into a caller-named `outputPath` (opened under the caller's -//! own identity to avoid a confused-deputy). There is **no real-time event access**; -//! denials are read from the ETL after the sandboxed process exits. +//! sandbox identified by the supplied security-environment handle. `Stop` seals and +//! copies the ETL into a caller-named `outputPath` (opened under the caller's own +//! identity to avoid a confused-deputy) and may be retried; `Close` releases the +//! broker state and staged ETL. There is **no real-time event access**; denials are +//! read from the ETL after the sandboxed process exits. //! //! Because the exports only exist on feature-enabled OS builds, this crate resolves //! them at runtime via `LoadLibrary`/`GetProcAddress` behind the [`is_learning_mode_api_available`] @@ -80,12 +82,21 @@ pub enum LearningModeError { detail: String, }, - /// An API call returned `FALSE`; `code` is the captured `GetLastError` value. - #[error("{function} failed (GetLastError = {code})")] - ApiCall { + /// An API call returned a failing HRESULT. + #[error("{function} failed (HRESULT = 0x{code:08X})")] + HResultCall { /// The name of the export that returned failure. function: &'static str, - /// The `GetLastError` value captured immediately after the failed call. + /// The raw HRESULT value. + code: i32, + }, + + /// A Win32 API call failed and set the thread's last-error value. + #[error("{function} failed (Win32 error = {code})")] + ApiCall { + /// The API operation that failed. + function: &'static str, + /// The raw `GetLastError` value. code: u32, }, @@ -97,15 +108,6 @@ pub enum LearningModeError { /// Why the value is invalid. detail: String, }, - - /// A primary operation failed and the subsequent cleanup operation also failed. - #[error("{primary}; cleanup also failed: {cleanup}")] - CleanupFailed { - /// The error that triggered cleanup. - primary: Box, - /// The error returned while attempting cleanup. - cleanup: Box, - }, } /// Capability probe: `true` only when `processmodel.dll` exposes the Learning Mode @@ -142,24 +144,6 @@ mod stub_tests { mod error_tests { use super::*; - #[test] - fn cleanup_error_preserves_both_failures() { - let error = LearningModeError::CleanupFailed { - primary: Box::new(LearningModeError::ApiCall { - function: "StartLearningModeTrace", - code: 5, - }), - cleanup: Box::new(LearningModeError::ApiCall { - function: "CloseProcessSecurityEnvironment", - code: 6, - }), - }; - - let message = error.to_string(); - assert!(message.contains("StartLearningModeTrace")); - assert!(message.contains("CloseProcessSecurityEnvironment")); - } - #[test] fn missing_export_identifies_the_api_surface() { let error = LearningModeError::ExportMissing { diff --git a/src/backends/learning_mode/windows/src/lifecycle.rs b/src/backends/learning_mode/windows/src/lifecycle.rs index b76e03369..ff8ad06bf 100644 --- a/src/backends/learning_mode/windows/src/lifecycle.rs +++ b/src/backends/learning_mode/windows/src/lifecycle.rs @@ -14,14 +14,15 @@ //! `CreateProcessW` (**runner's job**; the session exposes the handle via //! [`CaptureSession::environment`]) //! 4. wait for the child to exit -//! 5. `StopLearningModeTrace(trace, outputPath)` → sealed ETL (NULL path discards) -//! 6. `CloseProcessSecurityEnvironment(env)` → teardown +//! 5. `StopLearningModeTrace(trace, outputPath)` → sealed ETL (retryable delivery) +//! 6. `CloseLearningModeTrace(trace)` → release broker state and staged ETL +//! 7. `CloseProcessSecurityEnvironment(env)` → teardown //! //! [`CaptureSession::begin`] performs steps 1–2; the runner performs steps 3–4 with the //! handle from [`CaptureSession::environment`]; [`CaptureSession::finish`] performs steps -//! 5–6 in order. If the session is dropped without `finish` (e.g. the launch failed or a -//! `?` unwound the stack), [`Drop`] runs a best-effort teardown — discard the trace, then -//! close the environment — so no broker-side trace or environment is leaked. +//! 5–7 in order. If the session is dropped without `finish` (e.g. the launch failed or a +//! `?` unwound the stack), [`Drop`] closes the trace without stopping it — the OS-supported +//! discard path — then closes the environment. use std::path::Path; @@ -36,15 +37,13 @@ use crate::LearningModeError; /// /// Construct with [`CaptureSession::begin`]; drive the child launch with the handle from /// [`CaptureSession::environment`]; seal and tear down with [`CaptureSession::finish`]. -/// Dropping without `finish` discards the trace and closes the environment on a -/// best-effort basis. +/// Dropping without `finish` closes and discards the trace, then closes the environment. #[derive(Debug)] pub struct CaptureSession { - secenv_api: SecurityEnvironmentApi, learning_mode_api: LearningModeApi, /// `Some` until `finish`/`Drop` closes it. environment: Option, - /// `Some` until `finish`/`Drop` seals or discards it. + /// `Some` until `finish`/`Drop` closes it. trace: Option, } @@ -55,36 +54,28 @@ impl CaptureSession { /// `flags` is normally [`crate::PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE`]. /// /// # Errors - /// - [`LearningModeError::ApiCall`] if `CreateProcessSecurityEnvironment` fails. - /// - [`LearningModeError::ApiCall`] if `StartLearningModeTrace` fails — in which case - /// the just-created environment is closed before returning so it is not leaked. - /// - [`LearningModeError::CleanupFailed`] if starting the trace fails and closing - /// the just-created environment also fails. + /// - [`LearningModeError::HResultCall`] if `CreateProcessSecurityEnvironment` fails. + /// - [`LearningModeError::HResultCall`] if `StartLearningModeTrace` fails — in which + /// case the just-created environment is closed before returning so it is not leaked. pub fn begin( secenv_api: SecurityEnvironmentApi, learning_mode_api: LearningModeApi, sandbox_specification: &[u8], flags: u32, ) -> Result { - let mut environment = secenv_api.create(sandbox_specification, flags)?; + let environment = secenv_api.create(sandbox_specification, flags)?; // SAFETY: `environment` was just created by `secenv_api.create` and is live for // the duration of this call; `start_trace` only reads it. let trace = match unsafe { learning_mode_api.start_trace(environment.raw()) } { Ok(trace) => trace, Err(start_err) => { - return match secenv_api.close(&mut environment) { - Ok(()) => Err(start_err), - Err(cleanup) => Err(LearningModeError::CleanupFailed { - primary: Box::new(start_err), - cleanup: Box::new(cleanup), - }), - }; + environment.close(); + return Err(start_err); } }; Ok(Self { - secenv_api, learning_mode_api, environment: Some(environment), trace: Some(trace), @@ -110,97 +101,33 @@ impl CaptureSession { } } - /// Seal the trace to `output_path` (or discard it when `None`), then close the - /// security environment. Call **after** the child has exited. - /// - /// Both teardown steps are attempted even if the first fails. If both fail, - /// [`LearningModeError::CleanupFailed`] preserves both errors. + /// Stop the trace and deliver it to `output_path` (or skip delivery when `None`), + /// close the trace, then close the security environment. Call **after** the child + /// has exited. /// /// # Errors - /// - [`LearningModeError::ApiCall`] from `StopLearningModeTrace` or - /// `CloseProcessSecurityEnvironment`. - /// - [`LearningModeError::CleanupFailed`] if both teardown calls fail. + /// - [`LearningModeError::HResultCall`] from `StopLearningModeTrace`. pub fn finish(mut self, output_path: Option<&Path>) -> Result<(), LearningModeError> { - let stop_result = match self.trace.take() { + let stop_result = match self.trace.as_ref() { Some(trace) => self.learning_mode_api.stop_trace(trace, output_path), None => Ok(()), }; - let close_result = match self.environment.as_mut() { - Some(environment) => self.secenv_api.close(environment), - None => Ok(()), - }; - if close_result.is_ok() { - self.environment.take(); + if let Some(trace) = self.trace.take() { + trace.close(); } - combine_teardown_results(stop_result, close_result) - } -} - -fn combine_teardown_results( - stop_result: Result<(), LearningModeError>, - close_result: Result<(), LearningModeError>, -) -> Result<(), LearningModeError> { - match (stop_result, close_result) { - (Ok(()), Ok(())) => Ok(()), - (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), - (Err(primary), Err(cleanup)) => Err(LearningModeError::CleanupFailed { - primary: Box::new(primary), - cleanup: Box::new(cleanup), - }), + if let Some(environment) = self.environment.take() { + environment.close(); + } + stop_result } } impl Drop for CaptureSession { fn drop(&mut self) { - // Best-effort teardown for the early-exit / unwind path: discard the trace - // (NULL output path) before closing the environment. Errors are unrecoverable - // here and are intentionally ignored — `finish` is the fallible path. - if let Some(trace) = self.trace.take() { - let _ = self.learning_mode_api.stop_trace(trace, None); - } + // Close without Stop is the OS-supported early-exit discard path. + drop(self.trace.take()); if let Some(environment) = self.environment.take() { drop(environment); } } } - -#[cfg(test)] -mod tests { - use super::*; - - fn api_error(function: &'static str, code: u32) -> LearningModeError { - LearningModeError::ApiCall { function, code } - } - - #[test] - fn teardown_preserves_both_failures() { - let result = combine_teardown_results( - Err(api_error("StopLearningModeTrace", 5)), - Err(api_error("CloseProcessSecurityEnvironment", 6)), - ); - - let LearningModeError::CleanupFailed { primary, cleanup } = - result.expect_err("both teardown failures must be returned") - else { - panic!("expected CleanupFailed"); - }; - assert!(primary.to_string().contains("StopLearningModeTrace")); - assert!(cleanup - .to_string() - .contains("CloseProcessSecurityEnvironment")); - } - - #[test] - fn teardown_returns_single_failure_unchanged() { - let result = - combine_teardown_results(Ok(()), Err(api_error("CloseProcessSecurityEnvironment", 6))); - - assert!(matches!( - result, - Err(LearningModeError::ApiCall { - function: "CloseProcessSecurityEnvironment", - code: 6 - }) - )); - } -} diff --git a/src/backends/learning_mode/windows/src/secenv.rs b/src/backends/learning_mode/windows/src/secenv.rs index 610a7ba8d..f826732ae 100644 --- a/src/backends/learning_mode/windows/src/secenv.rs +++ b/src/backends/learning_mode/windows/src/secenv.rs @@ -14,23 +14,22 @@ //! 2-phase model exported by the same `processmodel.dll`: //! //! ```c -//! BOOL CreateProcessSecurityEnvironment( +//! HRESULT CreateProcessSecurityEnvironment( //! LPCVOID sandboxSpecification, DWORD sandboxSpecificationSize, //! PROCESS_SECURITY_ENVIRONMENT_FLAGS flags, //! HPROCESS_SECURITY_ENVIRONMENT* processSecurityEnvironment); -//! BOOL CloseProcessSecurityEnvironment(HPROCESS_SECURITY_ENVIRONMENT* processSecurityEnvironment); +//! void CloseProcessSecurityEnvironment(HPROCESS_SECURITY_ENVIRONMENT processSecurityEnvironment); //! ``` //! -//! `sandboxSpecification`/`...Size` is a compiled FlatBuffer sandbox-spec blob (the -//! same `"SBOX"` format the BaseContainer runner already builds via `sandbox_spec`); +//! `sandboxSpecification`/`...Size` is a `"PSEC"` process-security-environment +//! FlatBuffer; //! the spec must encode the learning-mode capability. The environment handle is //! attached to a normal `CreateProcessW` launch through //! `PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT`; KernelBase routes that launch //! through the security environment internally. `Close` tears the environment down. //! -//! As with the trace exports, each function is resolved at runtime and tolerates the -//! `Experimental_`-prefixed name as a fallback for OS builds that predate the -//! graduation out of the `Experimental_` prefix. +//! As with the trace exports, each function is resolved at runtime. The +//! ABI-changing create/close exports require their official plain names. use std::ffi::c_void; use std::ptr; @@ -43,7 +42,7 @@ use windows::Win32::System::Threading::{ DeleteProcThreadAttributeList, InitializeProcThreadAttributeList, UpdateProcThreadAttribute, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, STARTUPINFOEXW, STARTUPINFOW, }; -use windows_core::{PCSTR, PCWSTR}; +use windows_core::{HRESULT, PCSTR, PCWSTR}; use wxc_common::string_util; use crate::LearningModeError; @@ -54,13 +53,12 @@ const PROCESSMODEL_DLL: &str = "processmodel.dll"; /// No special behaviour when creating the security environment /// (`PROCESS_SECURITY_ENVIRONMENT_FLAGS` value `0`). /// -/// A `KILL_ON_CLOSE` bit exists (tears the child down when the environment closes) but -/// its numeric value is intentionally not declared here yet: explicit -/// [`SecurityEnvironmentApi::close`] after the child has exited already provides -/// deterministic teardown, so shipping code does not need to guess the flag value. +/// A terminate-on-close bit exists, but its numeric value is intentionally not +/// declared here: explicit [`ProcessSecurityEnvironment::close`] after the child +/// has exited already provides deterministic teardown. pub const PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE: u32 = 0; -/// `BOOL CreateProcessSecurityEnvironment(LPCVOID sandboxSpecification, +/// `HRESULT CreateProcessSecurityEnvironment(LPCVOID sandboxSpecification, /// DWORD sandboxSpecificationSize, PROCESS_SECURITY_ENVIRONMENT_FLAGS flags, /// HPROCESS_SECURITY_ENVIRONMENT* processSecurityEnvironment)`. /// @@ -70,22 +68,19 @@ type PfnCreateProcessSecurityEnvironment = unsafe extern "system" fn( sandbox_specification_size: u32, flags: u32, process_security_environment: *mut HANDLE, -) -> i32; +) -> HRESULT; -/// `BOOL CloseProcessSecurityEnvironment(HPROCESS_SECURITY_ENVIRONMENT* processSecurityEnvironment)`. -/// -/// The export nulls `*processSecurityEnvironment` on success. +/// `void CloseProcessSecurityEnvironment(HPROCESS_SECURITY_ENVIRONMENT processSecurityEnvironment)`. type PfnCloseProcessSecurityEnvironment = - unsafe extern "system" fn(process_security_environment: *mut HANDLE) -> i32; + unsafe extern "system" fn(process_security_environment: HANDLE); /// Opaque handle to a process security environment (`HPROCESS_SECURITY_ENVIRONMENT`, a /// `HANDLE`). /// /// Produced by [`SecurityEnvironmentApi::create`], threaded into the trace start and -/// the in-environment launch, and torn down by [`SecurityEnvironmentApi::close`]. The -/// wrapped [`HANDLE`] is passed by value to the launch/trace exports and by pointer to -/// the close export (which nulls it on success). If explicit close fails, the -/// wrapper retains ownership and retries once when dropped. +/// the in-environment launch, and torn down by [`ProcessSecurityEnvironment::close`]. The +/// wrapped [`HANDLE`] is passed by value to the launch, trace, and close exports. +/// Drop guarantees the infallible close is called exactly once. pub struct ProcessSecurityEnvironment { handle: HANDLE, close: PfnCloseProcessSecurityEnvironment, @@ -107,32 +102,26 @@ impl ProcessSecurityEnvironment { self.handle } - fn close_with( - &mut self, - close: PfnCloseProcessSecurityEnvironment, - ) -> Result<(), LearningModeError> { + /// Close the environment and release its server-side state. + pub fn close(mut self) { + self.close_inner(); + } + + fn close_inner(&mut self) { if self.handle.0.is_null() { - return Ok(()); + return; } // SAFETY: `close` was resolved from `processmodel.dll`; `self.handle` // came from a successful create call and remains owned by this wrapper. - let ok = unsafe { close(&mut self.handle) }; - if ok == 0 { - return Err(LearningModeError::ApiCall { - function: "CloseProcessSecurityEnvironment", - code: last_error(), - }); - } + unsafe { (self.close)(self.handle) }; self.handle = HANDLE(ptr::null_mut()); - Ok(()) } } impl Drop for ProcessSecurityEnvironment { fn drop(&mut self) { - let close = self.close; - let _ = self.close_with(close); + self.close_inner(); } } @@ -310,16 +299,8 @@ impl SecurityEnvironmentExportReport { } } -/// Candidate names for each export: the graduated (plain) name is preferred, with the -/// `Experimental_`-prefixed name kept as a fallback for older feature builds. -const CREATE_NAMES: &[&core::ffi::CStr] = &[ - c"CreateProcessSecurityEnvironment", - c"Experimental_CreateProcessSecurityEnvironment", -]; -const CLOSE_NAMES: &[&core::ffi::CStr] = &[ - c"CloseProcessSecurityEnvironment", - c"Experimental_CloseProcessSecurityEnvironment", -]; +const CREATE_NAMES: &[&core::ffi::CStr] = &[c"CreateProcessSecurityEnvironment"]; +const CLOSE_NAMES: &[&core::ffi::CStr] = &[c"CloseProcessSecurityEnvironment"]; /// Resolved process security-environment exports from `processmodel.dll`. #[derive(Clone, Copy)] @@ -342,8 +323,7 @@ impl SecurityEnvironmentApi { /// /// # Errors /// - [`LearningModeError::DllLoad`] if `processmodel.dll` cannot be loaded. - /// - [`LearningModeError::ExportMissing`] if any required export is absent under - /// either its plain or `Experimental_`-prefixed name. + /// - [`LearningModeError::ExportMissing`] if any required export is absent. pub fn load() -> Result { let dll = string_util::to_wide(PROCESSMODEL_DLL); @@ -372,30 +352,29 @@ impl SecurityEnvironmentApi { } } - /// Create a process security environment from a compiled FlatBuffer sandbox-spec + /// Create a process security environment from a PSEC FlatBuffer /// blob. `flags` is currently always [`PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE`]. /// /// # Errors - /// [`LearningModeError::ApiCall`] carrying `GetLastError` if the export returns - /// `FALSE` (including a spec larger than `u32::MAX`, reported as - /// `ERROR_INVALID_PARAMETER`). + /// [`LearningModeError::HResultCall`] if the export returns a failing HRESULT. pub fn create( &self, sandbox_specification: &[u8], flags: u32, ) -> Result { let mut env = HANDLE(ptr::null_mut()); - let spec_len = - u32::try_from(sandbox_specification.len()).map_err(|_| LearningModeError::ApiCall { + let spec_len = u32::try_from(sandbox_specification.len()).map_err(|_| { + LearningModeError::HResultCall { function: "CreateProcessSecurityEnvironment", - code: windows::Win32::Foundation::ERROR_INVALID_PARAMETER.0, - })?; + code: windows::Win32::Foundation::E_INVALIDARG.0, + } + })?; // SAFETY: `self.create` was resolved from `processmodel.dll` and matches the // declared C signature. `sandbox_specification`/`spec_len` describe a valid, // contiguous byte buffer that outlives the call, and `env` is a valid // out-pointer. - let ok = unsafe { + let result = unsafe { (self.create)( sandbox_specification.as_ptr().cast(), spec_len, @@ -403,10 +382,10 @@ impl SecurityEnvironmentApi { &mut env, ) }; - if ok == 0 { - return Err(LearningModeError::ApiCall { + if result.is_err() { + return Err(LearningModeError::HResultCall { function: "CreateProcessSecurityEnvironment", - code: last_error(), + code: result.0, }); } Ok(ProcessSecurityEnvironment { @@ -415,17 +394,6 @@ impl SecurityEnvironmentApi { }) } - /// Close a process security environment, tearing down its server-side state and - /// (per the create flags) the child. The export nulls the handle on success. - /// On failure, `env` retains ownership so the caller can retry; its [`Drop`] - /// implementation also makes one best-effort retry. - /// - /// # Errors - /// [`LearningModeError::ApiCall`] carrying `GetLastError` if the export returns - /// `FALSE`. - pub fn close(&self, env: &mut ProcessSecurityEnvironment) -> Result<(), LearningModeError> { - env.close_with(self.close) - } } /// Resolve the first name in `names` that is present in `hmodule`. @@ -519,16 +487,8 @@ mod tests { static CLOSE_CALLS: AtomicUsize = AtomicUsize::new(0); - unsafe extern "system" fn close_fails_then_succeeds(handle: *mut HANDLE) -> i32 { - if CLOSE_CALLS.fetch_add(1, Ordering::SeqCst) == 0 { - 0 - } else { - // SAFETY: the test passes a valid pointer to its owned HANDLE. - unsafe { - *handle = HANDLE(ptr::null_mut()); - } - 1 - } + unsafe extern "system" fn fake_close(_: HANDLE) { + CLOSE_CALLS.fetch_add(1, Ordering::SeqCst); } #[test] @@ -590,26 +550,14 @@ mod tests { } #[test] - fn failed_close_retains_ownership_and_drop_retries() { + fn explicit_close_is_exactly_once() { CLOSE_CALLS.store(0, Ordering::SeqCst); - let mut environment = ProcessSecurityEnvironment { + let environment = ProcessSecurityEnvironment { handle: HANDLE(std::ptr::dangling_mut::()), - close: close_fails_then_succeeds, + close: fake_close, }; - let error = environment - .close_with(close_fails_then_succeeds) - .expect_err("first close must fail"); - assert!(matches!( - error, - LearningModeError::ApiCall { - function: "CloseProcessSecurityEnvironment", - .. - } - )); - assert!(!environment.raw().0.is_null()); - - drop(environment); - assert_eq!(CLOSE_CALLS.load(Ordering::SeqCst), 2); + environment.close(); + assert_eq!(CLOSE_CALLS.load(Ordering::SeqCst), 1); } } diff --git a/src/core/generated/process_security_environment_specification/Cargo.toml b/src/core/generated/process_security_environment_specification/Cargo.toml new file mode 100644 index 000000000..04f9115b3 --- /dev/null +++ b/src/core/generated/process_security_environment_specification/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "process_security_environment_spec" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +description = "Generated FlatBuffers bindings for ProcessSecurityEnvironment" + +[dependencies] +flatbuffers = { workspace = true } diff --git a/src/core/generated/process_security_environment_specification/README.md b/src/core/generated/process_security_environment_specification/README.md new file mode 100644 index 000000000..d8db7231f --- /dev/null +++ b/src/core/generated/process_security_environment_specification/README.md @@ -0,0 +1,12 @@ +# Regenerating Process Security Environment bindings + +This crate contains Rust bindings generated from +`external/windows-sdk/ProcessSecurityEnvironment.fbs`. + +Install `flatc` 25.12.19 or newer, then run from the repository root: + +```powershell +pwsh -File src/core/generated/process_security_environment_specification/regenerate.ps1 +``` + +Pass `-Flatc ` when `flatc.exe` is not on `PATH`. diff --git a/src/core/generated/process_security_environment_specification/regenerate.ps1 b/src/core/generated/process_security_environment_specification/regenerate.ps1 new file mode 100644 index 000000000..1f1d808c8 --- /dev/null +++ b/src/core/generated/process_security_environment_specification/regenerate.ps1 @@ -0,0 +1,65 @@ +[CmdletBinding()] +param( + [string]$Flatc = "flatc.exe" +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = (& git rev-parse --show-toplevel) 2>$null +if (-not $repoRoot) { + throw "Not inside a git repository." +} +Set-Location $repoRoot + +$crateDir = $PSScriptRoot +$srcDir = Join-Path $crateDir "src" +$fbs = "external\windows-sdk\ProcessSecurityEnvironment.fbs" + +if (-not (Test-Path $fbs)) { + throw "FlatBuffers schema not found: $fbs" +} +if (-not (Test-Path $Flatc) -and -not (Get-Command $Flatc -ErrorAction SilentlyContinue)) { + throw "flatc not found: $Flatc" +} + +$minFlatcVersion = [version]'25.12.19' +$versionOutput = (& $Flatc --version) 2>&1 | Out-String +$match = [regex]::Match($versionOutput, 'flatc version (\d+\.\d+\.\d+)') +if (-not $match.Success) { + throw "Could not parse flatc version: $versionOutput" +} +if ([version]$match.Groups[1].Value -lt $minFlatcVersion) { + throw "flatc must be at least $minFlatcVersion" +} + +if (Test-Path $srcDir) { + Remove-Item $srcDir -Recurse -Force +} + +& $Flatc ` + --rust --gen-object-api --force-empty --no-prefix --rust-module-root-file --gen-all ` + -o $crateDir ` + $fbs +if ($LASTEXITCODE -ne 0) { + throw "flatc failed with exit code $LASTEXITCODE" +} + +New-Item -ItemType Directory -Path $srcDir | Out-Null +Move-Item (Join-Path $crateDir "mod.rs") (Join-Path $srcDir "lib.rs") +Move-Item (Join-Path $crateDir "process_security_environment_layout") ` + (Join-Path $srcDir "process_security_environment_layout") + +$libRs = Join-Path $srcDir "lib.rs" +(Get-Content $libRs) ` + -replace '// @generated', "// @generated`n#![allow(unused_imports, non_snake_case, non_camel_case_types, clippy::all)]" | + Set-Content $libRs + +Push-Location src +try { + & cargo fmt -p process_security_environment_spec + if ($LASTEXITCODE -ne 0) { + throw "cargo fmt failed with exit code $LASTEXITCODE" + } +} finally { + Pop-Location +} diff --git a/src/core/generated/process_security_environment_specification/src/lib.rs b/src/core/generated/process_security_environment_specification/src/lib.rs new file mode 100644 index 000000000..fd7e9b81e --- /dev/null +++ b/src/core/generated/process_security_environment_specification/src/lib.rs @@ -0,0 +1,28 @@ +// Automatically generated by the Flatbuffers compiler. Do not modify. +// @generated +#![allow(unused_imports, non_snake_case, non_camel_case_types, clippy::all)] +pub mod process_security_environment_layout { + use super::*; + mod filter_action_generated; + pub use self::filter_action_generated::*; + mod ip_protocol_generated; + pub use self::ip_protocol_generated::*; + mod schema_version_generated; + pub use self::schema_version_generated::*; + mod process_security_environment_generated; + pub use self::process_security_environment_generated::*; + mod proxy_info_generated; + pub use self::proxy_info_generated::*; + mod ip_subnet_generated; + pub use self::ip_subnet_generated::*; + mod destination_rule_generated; + pub use self::destination_rule_generated::*; + mod port_rule_generated; + pub use self::port_rule_generated::*; + mod endpoint_rule_generated; + pub use self::endpoint_rule_generated::*; + mod endpoint_policy_generated; + pub use self::endpoint_policy_generated::*; + mod network_policy_generated; + pub use self::network_policy_generated::*; +} // process_security_environment_layout diff --git a/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/destination_rule_generated.rs b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/destination_rule_generated.rs new file mode 100644 index 000000000..03f986475 --- /dev/null +++ b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/destination_rule_generated.rs @@ -0,0 +1,194 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +pub enum DestinationRuleOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct DestinationRule<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for DestinationRule<'a> { + type Inner = DestinationRule<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { + _tab: unsafe { ::flatbuffers::Table::new(buf, loc) }, + } + } +} + +impl<'a> DestinationRule<'a> { + pub const VT_SUBNET: ::flatbuffers::VOffsetT = 4; + pub const VT_EXCEPT: ::flatbuffers::VOffsetT = 6; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + DestinationRule { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: ::flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args DestinationRuleArgs<'args>, + ) -> ::flatbuffers::WIPOffset> { + let mut builder = DestinationRuleBuilder::new(_fbb); + if let Some(x) = args.except { + builder.add_except(x); + } + if let Some(x) = args.subnet { + builder.add_subnet(x); + } + builder.finish() + } + + pub fn unpack(&self) -> DestinationRuleT { + let subnet = self.subnet().map(|x| alloc::boxed::Box::new(x.unpack())); + let except = self + .except() + .map(|x| x.iter().map(|t| t.unpack()).collect()); + DestinationRuleT { subnet, except } + } + + #[inline] + pub fn subnet(&self) -> Option> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::<::flatbuffers::ForwardsUOffset>(DestinationRule::VT_SUBNET, None) + } + } + #[inline] + pub fn except( + &self, + ) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>, + >>(DestinationRule::VT_EXCEPT, None) + } + } +} + +impl ::flatbuffers::Verifiable for DestinationRule<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset>( + "subnet", + Self::VT_SUBNET, + false, + )? + .visit_field::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>, + >>("except", Self::VT_EXCEPT, false)? + .finish(); + Ok(()) + } +} +pub struct DestinationRuleArgs<'a> { + pub subnet: Option<::flatbuffers::WIPOffset>>, + pub except: Option< + ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>, + >, + >, +} +impl<'a> Default for DestinationRuleArgs<'a> { + #[inline] + fn default() -> Self { + DestinationRuleArgs { + subnet: None, + except: None, + } + } +} + +pub struct DestinationRuleBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> DestinationRuleBuilder<'a, 'b, A> { + #[inline] + pub fn add_subnet(&mut self, subnet: ::flatbuffers::WIPOffset>) { + self.fbb_ + .push_slot_always::<::flatbuffers::WIPOffset>( + DestinationRule::VT_SUBNET, + subnet, + ); + } + #[inline] + pub fn add_except( + &mut self, + except: ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'b, ::flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_ + .push_slot_always::<::flatbuffers::WIPOffset<_>>(DestinationRule::VT_EXCEPT, except); + } + #[inline] + pub fn new( + _fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + ) -> DestinationRuleBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + DestinationRuleBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for DestinationRule<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("DestinationRule"); + ds.field("subnet", &self.subnet()); + ds.field("except", &self.except()); + ds.finish() + } +} +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub struct DestinationRuleT { + pub subnet: Option>, + pub except: Option>, +} +impl Default for DestinationRuleT { + fn default() -> Self { + Self { + subnet: None, + except: None, + } + } +} +impl DestinationRuleT { + pub fn pack<'b, A: ::flatbuffers::Allocator + 'b>( + &self, + _fbb: &mut ::flatbuffers::FlatBufferBuilder<'b, A>, + ) -> ::flatbuffers::WIPOffset> { + let subnet = self.subnet.as_ref().map(|x| x.pack(_fbb)); + let except = self.except.as_ref().map(|x| { + let w: alloc::vec::Vec<_> = x.iter().map(|t| t.pack(_fbb)).collect(); + _fbb.create_vector(&w) + }); + DestinationRule::create(_fbb, &DestinationRuleArgs { subnet, except }) + } +} diff --git a/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/endpoint_policy_generated.rs b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/endpoint_policy_generated.rs new file mode 100644 index 000000000..db66f4a50 --- /dev/null +++ b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/endpoint_policy_generated.rs @@ -0,0 +1,242 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +pub enum EndpointPolicyOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct EndpointPolicy<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for EndpointPolicy<'a> { + type Inner = EndpointPolicy<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { + _tab: unsafe { ::flatbuffers::Table::new(buf, loc) }, + } + } +} + +impl<'a> EndpointPolicy<'a> { + pub const VT_DEFAULT_ACTION: ::flatbuffers::VOffsetT = 4; + pub const VT_ALLOW: ::flatbuffers::VOffsetT = 6; + pub const VT_DENY: ::flatbuffers::VOffsetT = 8; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + EndpointPolicy { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: ::flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args EndpointPolicyArgs<'args>, + ) -> ::flatbuffers::WIPOffset> { + let mut builder = EndpointPolicyBuilder::new(_fbb); + if let Some(x) = args.deny { + builder.add_deny(x); + } + if let Some(x) = args.allow { + builder.add_allow(x); + } + builder.add_default_action(args.default_action); + builder.finish() + } + + pub fn unpack(&self) -> EndpointPolicyT { + let default_action = self.default_action(); + let allow = self.allow().map(|x| x.iter().map(|t| t.unpack()).collect()); + let deny = self.deny().map(|x| x.iter().map(|t| t.unpack()).collect()); + EndpointPolicyT { + default_action, + allow, + deny, + } + } + + #[inline] + pub fn default_action(&self) -> FilterAction { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(EndpointPolicy::VT_DEFAULT_ACTION, Some(FilterAction::deny)) + .unwrap() + } + } + #[inline] + pub fn allow( + &self, + ) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>, + >>(EndpointPolicy::VT_ALLOW, None) + } + } + #[inline] + pub fn deny( + &self, + ) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>, + >>(EndpointPolicy::VT_DENY, None) + } + } +} + +impl ::flatbuffers::Verifiable for EndpointPolicy<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::("default_action", Self::VT_DEFAULT_ACTION, false)? + .visit_field::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>, + >>("allow", Self::VT_ALLOW, false)? + .visit_field::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>, + >>("deny", Self::VT_DENY, false)? + .finish(); + Ok(()) + } +} +pub struct EndpointPolicyArgs<'a> { + pub default_action: FilterAction, + pub allow: Option< + ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>, + >, + >, + pub deny: Option< + ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>, + >, + >, +} +impl<'a> Default for EndpointPolicyArgs<'a> { + #[inline] + fn default() -> Self { + EndpointPolicyArgs { + default_action: FilterAction::deny, + allow: None, + deny: None, + } + } +} + +pub struct EndpointPolicyBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> EndpointPolicyBuilder<'a, 'b, A> { + #[inline] + pub fn add_default_action(&mut self, default_action: FilterAction) { + self.fbb_.push_slot::( + EndpointPolicy::VT_DEFAULT_ACTION, + default_action, + FilterAction::deny, + ); + } + #[inline] + pub fn add_allow( + &mut self, + allow: ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'b, ::flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_ + .push_slot_always::<::flatbuffers::WIPOffset<_>>(EndpointPolicy::VT_ALLOW, allow); + } + #[inline] + pub fn add_deny( + &mut self, + deny: ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'b, ::flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_ + .push_slot_always::<::flatbuffers::WIPOffset<_>>(EndpointPolicy::VT_DENY, deny); + } + #[inline] + pub fn new( + _fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + ) -> EndpointPolicyBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + EndpointPolicyBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for EndpointPolicy<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("EndpointPolicy"); + ds.field("default_action", &self.default_action()); + ds.field("allow", &self.allow()); + ds.field("deny", &self.deny()); + ds.finish() + } +} +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub struct EndpointPolicyT { + pub default_action: FilterAction, + pub allow: Option>, + pub deny: Option>, +} +impl Default for EndpointPolicyT { + fn default() -> Self { + Self { + default_action: FilterAction::deny, + allow: None, + deny: None, + } + } +} +impl EndpointPolicyT { + pub fn pack<'b, A: ::flatbuffers::Allocator + 'b>( + &self, + _fbb: &mut ::flatbuffers::FlatBufferBuilder<'b, A>, + ) -> ::flatbuffers::WIPOffset> { + let default_action = self.default_action; + let allow = self.allow.as_ref().map(|x| { + let w: alloc::vec::Vec<_> = x.iter().map(|t| t.pack(_fbb)).collect(); + _fbb.create_vector(&w) + }); + let deny = self.deny.as_ref().map(|x| { + let w: alloc::vec::Vec<_> = x.iter().map(|t| t.pack(_fbb)).collect(); + _fbb.create_vector(&w) + }); + EndpointPolicy::create( + _fbb, + &EndpointPolicyArgs { + default_action, + allow, + deny, + }, + ) + } +} diff --git a/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/endpoint_rule_generated.rs b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/endpoint_rule_generated.rs new file mode 100644 index 000000000..b7deaa0ce --- /dev/null +++ b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/endpoint_rule_generated.rs @@ -0,0 +1,216 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +pub enum EndpointRuleOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct EndpointRule<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for EndpointRule<'a> { + type Inner = EndpointRule<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { + _tab: unsafe { ::flatbuffers::Table::new(buf, loc) }, + } + } +} + +impl<'a> EndpointRule<'a> { + pub const VT_DESTINATIONS: ::flatbuffers::VOffsetT = 4; + pub const VT_PORTS: ::flatbuffers::VOffsetT = 6; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + EndpointRule { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: ::flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args EndpointRuleArgs<'args>, + ) -> ::flatbuffers::WIPOffset> { + let mut builder = EndpointRuleBuilder::new(_fbb); + if let Some(x) = args.ports { + builder.add_ports(x); + } + if let Some(x) = args.destinations { + builder.add_destinations(x); + } + builder.finish() + } + + pub fn unpack(&self) -> EndpointRuleT { + let destinations = self + .destinations() + .map(|x| x.iter().map(|t| t.unpack()).collect()); + let ports = self.ports().map(|x| x.iter().map(|t| t.unpack()).collect()); + EndpointRuleT { + destinations, + ports, + } + } + + #[inline] + pub fn destinations( + &self, + ) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> + { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>, + >>(EndpointRule::VT_DESTINATIONS, None) + } + } + #[inline] + pub fn ports( + &self, + ) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>, + >>(EndpointRule::VT_PORTS, None) + } + } +} + +impl ::flatbuffers::Verifiable for EndpointRule<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>, + >>("destinations", Self::VT_DESTINATIONS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>, + >>("ports", Self::VT_PORTS, false)? + .finish(); + Ok(()) + } +} +pub struct EndpointRuleArgs<'a> { + pub destinations: Option< + ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>, + >, + >, + pub ports: Option< + ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>, + >, + >, +} +impl<'a> Default for EndpointRuleArgs<'a> { + #[inline] + fn default() -> Self { + EndpointRuleArgs { + destinations: None, + ports: None, + } + } +} + +pub struct EndpointRuleBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> EndpointRuleBuilder<'a, 'b, A> { + #[inline] + pub fn add_destinations( + &mut self, + destinations: ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'b, ::flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>( + EndpointRule::VT_DESTINATIONS, + destinations, + ); + } + #[inline] + pub fn add_ports( + &mut self, + ports: ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'b, ::flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_ + .push_slot_always::<::flatbuffers::WIPOffset<_>>(EndpointRule::VT_PORTS, ports); + } + #[inline] + pub fn new( + _fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + ) -> EndpointRuleBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + EndpointRuleBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for EndpointRule<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("EndpointRule"); + ds.field("destinations", &self.destinations()); + ds.field("ports", &self.ports()); + ds.finish() + } +} +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub struct EndpointRuleT { + pub destinations: Option>, + pub ports: Option>, +} +impl Default for EndpointRuleT { + fn default() -> Self { + Self { + destinations: None, + ports: None, + } + } +} +impl EndpointRuleT { + pub fn pack<'b, A: ::flatbuffers::Allocator + 'b>( + &self, + _fbb: &mut ::flatbuffers::FlatBufferBuilder<'b, A>, + ) -> ::flatbuffers::WIPOffset> { + let destinations = self.destinations.as_ref().map(|x| { + let w: alloc::vec::Vec<_> = x.iter().map(|t| t.pack(_fbb)).collect(); + _fbb.create_vector(&w) + }); + let ports = self.ports.as_ref().map(|x| { + let w: alloc::vec::Vec<_> = x.iter().map(|t| t.pack(_fbb)).collect(); + _fbb.create_vector(&w) + }); + EndpointRule::create( + _fbb, + &EndpointRuleArgs { + destinations, + ports, + }, + ) + } +} diff --git a/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/filter_action_generated.rs b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/filter_action_generated.rs new file mode 100644 index 000000000..027385b89 --- /dev/null +++ b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/filter_action_generated.rs @@ -0,0 +1,92 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MIN_FILTER_ACTION: i8 = 0; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MAX_FILTER_ACTION: i8 = 1; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_FILTER_ACTION: [FilterAction; 2] = [FilterAction::deny, FilterAction::allow]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct FilterAction(pub i8); +#[allow(non_upper_case_globals)] +impl FilterAction { + pub const deny: Self = Self(0); + pub const allow: Self = Self(1); + + pub const ENUM_MIN: i8 = 0; + pub const ENUM_MAX: i8 = 1; + pub const ENUM_VALUES: &'static [Self] = &[Self::deny, Self::allow]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::deny => Some("deny"), + Self::allow => Some("allow"), + _ => None, + } + } +} +impl ::core::fmt::Debug for FilterAction { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for FilterAction { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for FilterAction { + type Output = FilterAction; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for FilterAction { + type Scalar = i8; + #[inline] + fn to_little_endian(self) -> i8 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: i8) -> Self { + let b = i8::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for FilterAction { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + i8::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for FilterAction {} diff --git a/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/ip_protocol_generated.rs b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/ip_protocol_generated.rs new file mode 100644 index 000000000..3dc6bfa28 --- /dev/null +++ b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/ip_protocol_generated.rs @@ -0,0 +1,105 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MIN_IP_PROTOCOL: i8 = 0; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MAX_IP_PROTOCOL: i8 = 4; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_IP_PROTOCOL: [IpProtocol; 5] = [ + IpProtocol::any, + IpProtocol::tcp, + IpProtocol::udp, + IpProtocol::icmpv4, + IpProtocol::icmpv6, +]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct IpProtocol(pub i8); +#[allow(non_upper_case_globals)] +impl IpProtocol { + pub const any: Self = Self(0); + pub const tcp: Self = Self(1); + pub const udp: Self = Self(2); + pub const icmpv4: Self = Self(3); + pub const icmpv6: Self = Self(4); + + pub const ENUM_MIN: i8 = 0; + pub const ENUM_MAX: i8 = 4; + pub const ENUM_VALUES: &'static [Self] = + &[Self::any, Self::tcp, Self::udp, Self::icmpv4, Self::icmpv6]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::any => Some("any"), + Self::tcp => Some("tcp"), + Self::udp => Some("udp"), + Self::icmpv4 => Some("icmpv4"), + Self::icmpv6 => Some("icmpv6"), + _ => None, + } + } +} +impl ::core::fmt::Debug for IpProtocol { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for IpProtocol { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for IpProtocol { + type Output = IpProtocol; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for IpProtocol { + type Scalar = i8; + #[inline] + fn to_little_endian(self) -> i8 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: i8) -> Self { + let b = i8::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for IpProtocol { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + i8::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for IpProtocol {} diff --git a/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/ip_subnet_generated.rs b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/ip_subnet_generated.rs new file mode 100644 index 000000000..a51fdb4f1 --- /dev/null +++ b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/ip_subnet_generated.rs @@ -0,0 +1,182 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +pub enum IpSubnetOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct IpSubnet<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for IpSubnet<'a> { + type Inner = IpSubnet<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { + _tab: unsafe { ::flatbuffers::Table::new(buf, loc) }, + } + } +} + +impl<'a> IpSubnet<'a> { + pub const VT_ADDRESS: ::flatbuffers::VOffsetT = 4; + pub const VT_PREFIX_LENGTH: ::flatbuffers::VOffsetT = 6; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + IpSubnet { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: ::flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args IpSubnetArgs<'args>, + ) -> ::flatbuffers::WIPOffset> { + let mut builder = IpSubnetBuilder::new(_fbb); + if let Some(x) = args.address { + builder.add_address(x); + } + builder.add_prefix_length(args.prefix_length); + builder.finish() + } + + pub fn unpack(&self) -> IpSubnetT { + let address = self + .address() + .map(|x| alloc::string::ToString::to_string(x)); + let prefix_length = self.prefix_length(); + IpSubnetT { + address, + prefix_length, + } + } + + #[inline] + pub fn address(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::<::flatbuffers::ForwardsUOffset<&str>>(IpSubnet::VT_ADDRESS, None) + } + } + #[inline] + pub fn prefix_length(&self) -> u8 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(IpSubnet::VT_PREFIX_LENGTH, Some(0)) + .unwrap() + } + } +} + +impl ::flatbuffers::Verifiable for IpSubnet<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>( + "address", + Self::VT_ADDRESS, + false, + )? + .visit_field::("prefix_length", Self::VT_PREFIX_LENGTH, false)? + .finish(); + Ok(()) + } +} +pub struct IpSubnetArgs<'a> { + pub address: Option<::flatbuffers::WIPOffset<&'a str>>, + pub prefix_length: u8, +} +impl<'a> Default for IpSubnetArgs<'a> { + #[inline] + fn default() -> Self { + IpSubnetArgs { + address: None, + prefix_length: 0, + } + } +} + +pub struct IpSubnetBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> IpSubnetBuilder<'a, 'b, A> { + #[inline] + pub fn add_address(&mut self, address: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_ + .push_slot_always::<::flatbuffers::WIPOffset<_>>(IpSubnet::VT_ADDRESS, address); + } + #[inline] + pub fn add_prefix_length(&mut self, prefix_length: u8) { + self.fbb_ + .push_slot::(IpSubnet::VT_PREFIX_LENGTH, prefix_length, 0); + } + #[inline] + pub fn new( + _fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + ) -> IpSubnetBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + IpSubnetBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for IpSubnet<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("IpSubnet"); + ds.field("address", &self.address()); + ds.field("prefix_length", &self.prefix_length()); + ds.finish() + } +} +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub struct IpSubnetT { + pub address: Option, + pub prefix_length: u8, +} +impl Default for IpSubnetT { + fn default() -> Self { + Self { + address: None, + prefix_length: 0, + } + } +} +impl IpSubnetT { + pub fn pack<'b, A: ::flatbuffers::Allocator + 'b>( + &self, + _fbb: &mut ::flatbuffers::FlatBufferBuilder<'b, A>, + ) -> ::flatbuffers::WIPOffset> { + let address = self.address.as_ref().map(|x| _fbb.create_string(x)); + let prefix_length = self.prefix_length; + IpSubnet::create( + _fbb, + &IpSubnetArgs { + address, + prefix_length, + }, + ) + } +} diff --git a/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/network_policy_generated.rs b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/network_policy_generated.rs new file mode 100644 index 000000000..d619b7038 --- /dev/null +++ b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/network_policy_generated.rs @@ -0,0 +1,242 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +pub enum NetworkPolicyOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct NetworkPolicy<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for NetworkPolicy<'a> { + type Inner = NetworkPolicy<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { + _tab: unsafe { ::flatbuffers::Table::new(buf, loc) }, + } + } +} + +impl<'a> NetworkPolicy<'a> { + pub const VT_PROXY: ::flatbuffers::VOffsetT = 4; + pub const VT_EGRESS: ::flatbuffers::VOffsetT = 6; + pub const VT_ALLOWED_APPCONTAINER_PEER: ::flatbuffers::VOffsetT = 8; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + NetworkPolicy { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: ::flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args NetworkPolicyArgs<'args>, + ) -> ::flatbuffers::WIPOffset> { + let mut builder = NetworkPolicyBuilder::new(_fbb); + if let Some(x) = args.allowed_appcontainer_peer { + builder.add_allowed_appcontainer_peer(x); + } + if let Some(x) = args.egress { + builder.add_egress(x); + } + if let Some(x) = args.proxy { + builder.add_proxy(x); + } + builder.finish() + } + + pub fn unpack(&self) -> NetworkPolicyT { + let proxy = self.proxy().map(|x| alloc::boxed::Box::new(x.unpack())); + let egress = self.egress().map(|x| alloc::boxed::Box::new(x.unpack())); + let allowed_appcontainer_peer = self + .allowed_appcontainer_peer() + .map(|x| alloc::string::ToString::to_string(x)); + NetworkPolicyT { + proxy, + egress, + allowed_appcontainer_peer, + } + } + + #[inline] + pub fn proxy(&self) -> Option> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::<::flatbuffers::ForwardsUOffset>(NetworkPolicy::VT_PROXY, None) + } + } + #[inline] + pub fn egress(&self) -> Option> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::<::flatbuffers::ForwardsUOffset>( + NetworkPolicy::VT_EGRESS, + None, + ) + } + } + #[inline] + pub fn allowed_appcontainer_peer(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>( + NetworkPolicy::VT_ALLOWED_APPCONTAINER_PEER, + None, + ) + } + } +} + +impl ::flatbuffers::Verifiable for NetworkPolicy<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset>( + "proxy", + Self::VT_PROXY, + false, + )? + .visit_field::<::flatbuffers::ForwardsUOffset>( + "egress", + Self::VT_EGRESS, + false, + )? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>( + "allowed_appcontainer_peer", + Self::VT_ALLOWED_APPCONTAINER_PEER, + false, + )? + .finish(); + Ok(()) + } +} +pub struct NetworkPolicyArgs<'a> { + pub proxy: Option<::flatbuffers::WIPOffset>>, + pub egress: Option<::flatbuffers::WIPOffset>>, + pub allowed_appcontainer_peer: Option<::flatbuffers::WIPOffset<&'a str>>, +} +impl<'a> Default for NetworkPolicyArgs<'a> { + #[inline] + fn default() -> Self { + NetworkPolicyArgs { + proxy: None, + egress: None, + allowed_appcontainer_peer: None, + } + } +} + +pub struct NetworkPolicyBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> NetworkPolicyBuilder<'a, 'b, A> { + #[inline] + pub fn add_proxy(&mut self, proxy: ::flatbuffers::WIPOffset>) { + self.fbb_ + .push_slot_always::<::flatbuffers::WIPOffset>( + NetworkPolicy::VT_PROXY, + proxy, + ); + } + #[inline] + pub fn add_egress(&mut self, egress: ::flatbuffers::WIPOffset>) { + self.fbb_ + .push_slot_always::<::flatbuffers::WIPOffset>( + NetworkPolicy::VT_EGRESS, + egress, + ); + } + #[inline] + pub fn add_allowed_appcontainer_peer( + &mut self, + allowed_appcontainer_peer: ::flatbuffers::WIPOffset<&'b str>, + ) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>( + NetworkPolicy::VT_ALLOWED_APPCONTAINER_PEER, + allowed_appcontainer_peer, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + ) -> NetworkPolicyBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + NetworkPolicyBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for NetworkPolicy<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("NetworkPolicy"); + ds.field("proxy", &self.proxy()); + ds.field("egress", &self.egress()); + ds.field( + "allowed_appcontainer_peer", + &self.allowed_appcontainer_peer(), + ); + ds.finish() + } +} +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub struct NetworkPolicyT { + pub proxy: Option>, + pub egress: Option>, + pub allowed_appcontainer_peer: Option, +} +impl Default for NetworkPolicyT { + fn default() -> Self { + Self { + proxy: None, + egress: None, + allowed_appcontainer_peer: None, + } + } +} +impl NetworkPolicyT { + pub fn pack<'b, A: ::flatbuffers::Allocator + 'b>( + &self, + _fbb: &mut ::flatbuffers::FlatBufferBuilder<'b, A>, + ) -> ::flatbuffers::WIPOffset> { + let proxy = self.proxy.as_ref().map(|x| x.pack(_fbb)); + let egress = self.egress.as_ref().map(|x| x.pack(_fbb)); + let allowed_appcontainer_peer = self + .allowed_appcontainer_peer + .as_ref() + .map(|x| _fbb.create_string(x)); + NetworkPolicy::create( + _fbb, + &NetworkPolicyArgs { + proxy, + egress, + allowed_appcontainer_peer, + }, + ) + } +} diff --git a/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/port_rule_generated.rs b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/port_rule_generated.rs new file mode 100644 index 000000000..e689be18d --- /dev/null +++ b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/port_rule_generated.rs @@ -0,0 +1,198 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +pub enum PortRuleOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct PortRule<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for PortRule<'a> { + type Inner = PortRule<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { + _tab: unsafe { ::flatbuffers::Table::new(buf, loc) }, + } + } +} + +impl<'a> PortRule<'a> { + pub const VT_PROTOCOL: ::flatbuffers::VOffsetT = 4; + pub const VT_PORT: ::flatbuffers::VOffsetT = 6; + pub const VT_END_PORT: ::flatbuffers::VOffsetT = 8; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + PortRule { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: ::flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args PortRuleArgs, + ) -> ::flatbuffers::WIPOffset> { + let mut builder = PortRuleBuilder::new(_fbb); + builder.add_end_port(args.end_port); + builder.add_port(args.port); + builder.add_protocol(args.protocol); + builder.finish() + } + + pub fn unpack(&self) -> PortRuleT { + let protocol = self.protocol(); + let port = self.port(); + let end_port = self.end_port(); + PortRuleT { + protocol, + port, + end_port, + } + } + + #[inline] + pub fn protocol(&self) -> IpProtocol { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PortRule::VT_PROTOCOL, Some(IpProtocol::any)) + .unwrap() + } + } + #[inline] + pub fn port(&self) -> u16 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(PortRule::VT_PORT, Some(0)).unwrap() } + } + #[inline] + pub fn end_port(&self) -> u16 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PortRule::VT_END_PORT, Some(0)) + .unwrap() + } + } +} + +impl ::flatbuffers::Verifiable for PortRule<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::("protocol", Self::VT_PROTOCOL, false)? + .visit_field::("port", Self::VT_PORT, false)? + .visit_field::("end_port", Self::VT_END_PORT, false)? + .finish(); + Ok(()) + } +} +pub struct PortRuleArgs { + pub protocol: IpProtocol, + pub port: u16, + pub end_port: u16, +} +impl<'a> Default for PortRuleArgs { + #[inline] + fn default() -> Self { + PortRuleArgs { + protocol: IpProtocol::any, + port: 0, + end_port: 0, + } + } +} + +pub struct PortRuleBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> PortRuleBuilder<'a, 'b, A> { + #[inline] + pub fn add_protocol(&mut self, protocol: IpProtocol) { + self.fbb_ + .push_slot::(PortRule::VT_PROTOCOL, protocol, IpProtocol::any); + } + #[inline] + pub fn add_port(&mut self, port: u16) { + self.fbb_.push_slot::(PortRule::VT_PORT, port, 0); + } + #[inline] + pub fn add_end_port(&mut self, end_port: u16) { + self.fbb_ + .push_slot::(PortRule::VT_END_PORT, end_port, 0); + } + #[inline] + pub fn new( + _fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + ) -> PortRuleBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + PortRuleBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for PortRule<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("PortRule"); + ds.field("protocol", &self.protocol()); + ds.field("port", &self.port()); + ds.field("end_port", &self.end_port()); + ds.finish() + } +} +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub struct PortRuleT { + pub protocol: IpProtocol, + pub port: u16, + pub end_port: u16, +} +impl Default for PortRuleT { + fn default() -> Self { + Self { + protocol: IpProtocol::any, + port: 0, + end_port: 0, + } + } +} +impl PortRuleT { + pub fn pack<'b, A: ::flatbuffers::Allocator + 'b>( + &self, + _fbb: &mut ::flatbuffers::FlatBufferBuilder<'b, A>, + ) -> ::flatbuffers::WIPOffset> { + let protocol = self.protocol; + let port = self.port; + let end_port = self.end_port; + PortRule::create( + _fbb, + &PortRuleArgs { + protocol, + port, + end_port, + }, + ) + } +} diff --git a/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/process_security_environment_generated.rs b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/process_security_environment_generated.rs new file mode 100644 index 000000000..254fbefc5 --- /dev/null +++ b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/process_security_environment_generated.rs @@ -0,0 +1,565 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +pub enum ProcessSecurityEnvironmentOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct ProcessSecurityEnvironment<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for ProcessSecurityEnvironment<'a> { + type Inner = ProcessSecurityEnvironment<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { + _tab: unsafe { ::flatbuffers::Table::new(buf, loc) }, + } + } +} + +impl<'a> ProcessSecurityEnvironment<'a> { + pub const VT_VERSION: ::flatbuffers::VOffsetT = 4; + pub const VT_CAPABILITIES: ::flatbuffers::VOffsetT = 6; + pub const VT_DISALLOW_WIN32K_SYSTEM_CALLS: ::flatbuffers::VOffsetT = 8; + pub const VT_UI_RESTRICTIONS: ::flatbuffers::VOffsetT = 10; + pub const VT_FS_READ_WRITE: ::flatbuffers::VOffsetT = 12; + pub const VT_FS_READ_ONLY: ::flatbuffers::VOffsetT = 14; + pub const VT_FS_DENY: ::flatbuffers::VOffsetT = 16; + pub const VT_NETWORK_POLICY: ::flatbuffers::VOffsetT = 18; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + ProcessSecurityEnvironment { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: ::flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args ProcessSecurityEnvironmentArgs<'args>, + ) -> ::flatbuffers::WIPOffset> { + let mut builder = ProcessSecurityEnvironmentBuilder::new(_fbb); + builder.add_ui_restrictions(args.ui_restrictions); + if let Some(x) = args.network_policy { + builder.add_network_policy(x); + } + if let Some(x) = args.fs_deny { + builder.add_fs_deny(x); + } + if let Some(x) = args.fs_read_only { + builder.add_fs_read_only(x); + } + if let Some(x) = args.fs_read_write { + builder.add_fs_read_write(x); + } + if let Some(x) = args.capabilities { + builder.add_capabilities(x); + } + if let Some(x) = args.version { + builder.add_version(x); + } + builder.add_disallow_win32k_system_calls(args.disallow_win32k_system_calls); + builder.finish() + } + + pub fn unpack(&self) -> ProcessSecurityEnvironmentT { + let version = { + let x = self.version(); + x.unpack() + }; + let capabilities = self + .capabilities() + .map(|x| alloc::string::ToString::to_string(x)); + let disallow_win32k_system_calls = self.disallow_win32k_system_calls(); + let ui_restrictions = self.ui_restrictions(); + let fs_read_write = self.fs_read_write().map(|x| { + x.iter() + .map(|s| alloc::string::ToString::to_string(s)) + .collect() + }); + let fs_read_only = self.fs_read_only().map(|x| { + x.iter() + .map(|s| alloc::string::ToString::to_string(s)) + .collect() + }); + let fs_deny = self.fs_deny().map(|x| { + x.iter() + .map(|s| alloc::string::ToString::to_string(s)) + .collect() + }); + let network_policy = self + .network_policy() + .map(|x| alloc::boxed::Box::new(x.unpack())); + ProcessSecurityEnvironmentT { + version, + capabilities, + disallow_win32k_system_calls, + ui_restrictions, + fs_read_write, + fs_read_only, + fs_deny, + network_policy, + } + } + + #[inline] + pub fn version(&self) -> &'a SchemaVersion { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(ProcessSecurityEnvironment::VT_VERSION, None) + .unwrap() + } + } + #[inline] + pub fn capabilities(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::<::flatbuffers::ForwardsUOffset<&str>>( + ProcessSecurityEnvironment::VT_CAPABILITIES, + None, + ) + } + } + #[inline] + pub fn disallow_win32k_system_calls(&self) -> bool { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::( + ProcessSecurityEnvironment::VT_DISALLOW_WIN32K_SYSTEM_CALLS, + Some(false), + ) + .unwrap() + } + } + #[inline] + pub fn ui_restrictions(&self) -> u64 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(ProcessSecurityEnvironment::VT_UI_RESTRICTIONS, Some(0)) + .unwrap() + } + } + #[inline] + pub fn fs_read_write( + &self, + ) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>, + >>(ProcessSecurityEnvironment::VT_FS_READ_WRITE, None) + } + } + #[inline] + pub fn fs_read_only( + &self, + ) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>, + >>(ProcessSecurityEnvironment::VT_FS_READ_ONLY, None) + } + } + #[inline] + pub fn fs_deny( + &self, + ) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>, + >>(ProcessSecurityEnvironment::VT_FS_DENY, None) + } + } + #[inline] + pub fn network_policy(&self) -> Option> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::<::flatbuffers::ForwardsUOffset>( + ProcessSecurityEnvironment::VT_NETWORK_POLICY, + None, + ) + } + } +} + +impl ::flatbuffers::Verifiable for ProcessSecurityEnvironment<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::("version", Self::VT_VERSION, true)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>( + "capabilities", + Self::VT_CAPABILITIES, + false, + )? + .visit_field::( + "disallow_win32k_system_calls", + Self::VT_DISALLOW_WIN32K_SYSTEM_CALLS, + false, + )? + .visit_field::("ui_restrictions", Self::VT_UI_RESTRICTIONS, false)? + .visit_field::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset<&'_ str>>, + >>("fs_read_write", Self::VT_FS_READ_WRITE, false)? + .visit_field::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset<&'_ str>>, + >>("fs_read_only", Self::VT_FS_READ_ONLY, false)? + .visit_field::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset<&'_ str>>, + >>("fs_deny", Self::VT_FS_DENY, false)? + .visit_field::<::flatbuffers::ForwardsUOffset>( + "network_policy", + Self::VT_NETWORK_POLICY, + false, + )? + .finish(); + Ok(()) + } +} +pub struct ProcessSecurityEnvironmentArgs<'a> { + pub version: Option<&'a SchemaVersion>, + pub capabilities: Option<::flatbuffers::WIPOffset<&'a str>>, + pub disallow_win32k_system_calls: bool, + pub ui_restrictions: u64, + pub fs_read_write: Option< + ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>, + >, + >, + pub fs_read_only: Option< + ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>, + >, + >, + pub fs_deny: Option< + ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>, + >, + >, + pub network_policy: Option<::flatbuffers::WIPOffset>>, +} +impl<'a> Default for ProcessSecurityEnvironmentArgs<'a> { + #[inline] + fn default() -> Self { + ProcessSecurityEnvironmentArgs { + version: None, // required field + capabilities: None, + disallow_win32k_system_calls: false, + ui_restrictions: 0, + fs_read_write: None, + fs_read_only: None, + fs_deny: None, + network_policy: None, + } + } +} + +pub struct ProcessSecurityEnvironmentBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> ProcessSecurityEnvironmentBuilder<'a, 'b, A> { + #[inline] + pub fn add_version(&mut self, version: &SchemaVersion) { + self.fbb_ + .push_slot_always::<&SchemaVersion>(ProcessSecurityEnvironment::VT_VERSION, version); + } + #[inline] + pub fn add_capabilities(&mut self, capabilities: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>( + ProcessSecurityEnvironment::VT_CAPABILITIES, + capabilities, + ); + } + #[inline] + pub fn add_disallow_win32k_system_calls(&mut self, disallow_win32k_system_calls: bool) { + self.fbb_.push_slot::( + ProcessSecurityEnvironment::VT_DISALLOW_WIN32K_SYSTEM_CALLS, + disallow_win32k_system_calls, + false, + ); + } + #[inline] + pub fn add_ui_restrictions(&mut self, ui_restrictions: u64) { + self.fbb_.push_slot::( + ProcessSecurityEnvironment::VT_UI_RESTRICTIONS, + ui_restrictions, + 0, + ); + } + #[inline] + pub fn add_fs_read_write( + &mut self, + fs_read_write: ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'b, ::flatbuffers::ForwardsUOffset<&'b str>>, + >, + ) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>( + ProcessSecurityEnvironment::VT_FS_READ_WRITE, + fs_read_write, + ); + } + #[inline] + pub fn add_fs_read_only( + &mut self, + fs_read_only: ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'b, ::flatbuffers::ForwardsUOffset<&'b str>>, + >, + ) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>( + ProcessSecurityEnvironment::VT_FS_READ_ONLY, + fs_read_only, + ); + } + #[inline] + pub fn add_fs_deny( + &mut self, + fs_deny: ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'b, ::flatbuffers::ForwardsUOffset<&'b str>>, + >, + ) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>( + ProcessSecurityEnvironment::VT_FS_DENY, + fs_deny, + ); + } + #[inline] + pub fn add_network_policy( + &mut self, + network_policy: ::flatbuffers::WIPOffset>, + ) { + self.fbb_ + .push_slot_always::<::flatbuffers::WIPOffset>( + ProcessSecurityEnvironment::VT_NETWORK_POLICY, + network_policy, + ); + } + #[inline] + pub fn new( + _fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + ) -> ProcessSecurityEnvironmentBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + ProcessSecurityEnvironmentBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + self.fbb_ + .required(o, ProcessSecurityEnvironment::VT_VERSION, "version"); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for ProcessSecurityEnvironment<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("ProcessSecurityEnvironment"); + ds.field("version", &self.version()); + ds.field("capabilities", &self.capabilities()); + ds.field( + "disallow_win32k_system_calls", + &self.disallow_win32k_system_calls(), + ); + ds.field("ui_restrictions", &self.ui_restrictions()); + ds.field("fs_read_write", &self.fs_read_write()); + ds.field("fs_read_only", &self.fs_read_only()); + ds.field("fs_deny", &self.fs_deny()); + ds.field("network_policy", &self.network_policy()); + ds.finish() + } +} +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub struct ProcessSecurityEnvironmentT { + pub version: SchemaVersionT, + pub capabilities: Option, + pub disallow_win32k_system_calls: bool, + pub ui_restrictions: u64, + pub fs_read_write: Option>, + pub fs_read_only: Option>, + pub fs_deny: Option>, + pub network_policy: Option>, +} +impl Default for ProcessSecurityEnvironmentT { + fn default() -> Self { + Self { + version: Default::default(), + capabilities: None, + disallow_win32k_system_calls: false, + ui_restrictions: 0, + fs_read_write: None, + fs_read_only: None, + fs_deny: None, + network_policy: None, + } + } +} +impl ProcessSecurityEnvironmentT { + pub fn pack<'b, A: ::flatbuffers::Allocator + 'b>( + &self, + _fbb: &mut ::flatbuffers::FlatBufferBuilder<'b, A>, + ) -> ::flatbuffers::WIPOffset> { + let version_tmp = Some(self.version.pack()); + let version = version_tmp.as_ref(); + let capabilities = self.capabilities.as_ref().map(|x| _fbb.create_string(x)); + let disallow_win32k_system_calls = self.disallow_win32k_system_calls; + let ui_restrictions = self.ui_restrictions; + let fs_read_write = self.fs_read_write.as_ref().map(|x| { + let w: alloc::vec::Vec<_> = x.iter().map(|s| _fbb.create_string(s)).collect(); + _fbb.create_vector(&w) + }); + let fs_read_only = self.fs_read_only.as_ref().map(|x| { + let w: alloc::vec::Vec<_> = x.iter().map(|s| _fbb.create_string(s)).collect(); + _fbb.create_vector(&w) + }); + let fs_deny = self.fs_deny.as_ref().map(|x| { + let w: alloc::vec::Vec<_> = x.iter().map(|s| _fbb.create_string(s)).collect(); + _fbb.create_vector(&w) + }); + let network_policy = self.network_policy.as_ref().map(|x| x.pack(_fbb)); + ProcessSecurityEnvironment::create( + _fbb, + &ProcessSecurityEnvironmentArgs { + version, + capabilities, + disallow_win32k_system_calls, + ui_restrictions, + fs_read_write, + fs_read_only, + fs_deny, + network_policy, + }, + ) + } +} +#[inline] +/// Verifies that a buffer of bytes contains a `ProcessSecurityEnvironment` +/// and returns it. +/// Note that verification is still experimental and may not +/// catch every error, or be maximally performant. For the +/// previous, unchecked, behavior use +/// `root_as_process_security_environment_unchecked`. +pub fn root_as_process_security_environment( + buf: &[u8], +) -> Result, ::flatbuffers::InvalidFlatbuffer> { + ::flatbuffers::root::(buf) +} +#[inline] +/// Verifies that a buffer of bytes contains a size prefixed +/// `ProcessSecurityEnvironment` and returns it. +/// Note that verification is still experimental and may not +/// catch every error, or be maximally performant. For the +/// previous, unchecked, behavior use +/// `size_prefixed_root_as_process_security_environment_unchecked`. +pub fn size_prefixed_root_as_process_security_environment( + buf: &[u8], +) -> Result, ::flatbuffers::InvalidFlatbuffer> { + ::flatbuffers::size_prefixed_root::(buf) +} +#[inline] +/// Verifies, with the given options, that a buffer of bytes +/// contains a `ProcessSecurityEnvironment` and returns it. +/// Note that verification is still experimental and may not +/// catch every error, or be maximally performant. For the +/// previous, unchecked, behavior use +/// `root_as_process_security_environment_unchecked`. +pub fn root_as_process_security_environment_with_opts<'b, 'o>( + opts: &'o ::flatbuffers::VerifierOptions, + buf: &'b [u8], +) -> Result, ::flatbuffers::InvalidFlatbuffer> { + ::flatbuffers::root_with_opts::>(opts, buf) +} +#[inline] +/// Verifies, with the given verifier options, that a buffer of +/// bytes contains a size prefixed `ProcessSecurityEnvironment` and returns +/// it. Note that verification is still experimental and may not +/// catch every error, or be maximally performant. For the +/// previous, unchecked, behavior use +/// `root_as_process_security_environment_unchecked`. +pub fn size_prefixed_root_as_process_security_environment_with_opts<'b, 'o>( + opts: &'o ::flatbuffers::VerifierOptions, + buf: &'b [u8], +) -> Result, ::flatbuffers::InvalidFlatbuffer> { + ::flatbuffers::size_prefixed_root_with_opts::>(opts, buf) +} +#[inline] +/// Assumes, without verification, that a buffer of bytes contains a ProcessSecurityEnvironment and returns it. +/// # Safety +/// Callers must trust the given bytes do indeed contain a valid `ProcessSecurityEnvironment`. +pub unsafe fn root_as_process_security_environment_unchecked( + buf: &[u8], +) -> ProcessSecurityEnvironment<'_> { + unsafe { ::flatbuffers::root_unchecked::(buf) } +} +#[inline] +/// Assumes, without verification, that a buffer of bytes contains a size prefixed ProcessSecurityEnvironment and returns it. +/// # Safety +/// Callers must trust the given bytes do indeed contain a valid size prefixed `ProcessSecurityEnvironment`. +pub unsafe fn size_prefixed_root_as_process_security_environment_unchecked( + buf: &[u8], +) -> ProcessSecurityEnvironment<'_> { + unsafe { ::flatbuffers::size_prefixed_root_unchecked::(buf) } +} +pub const PROCESS_SECURITY_ENVIRONMENT_IDENTIFIER: &str = "PSEC"; + +#[inline] +pub fn process_security_environment_buffer_has_identifier(buf: &[u8]) -> bool { + ::flatbuffers::buffer_has_identifier(buf, PROCESS_SECURITY_ENVIRONMENT_IDENTIFIER, false) +} + +#[inline] +pub fn process_security_environment_size_prefixed_buffer_has_identifier(buf: &[u8]) -> bool { + ::flatbuffers::buffer_has_identifier(buf, PROCESS_SECURITY_ENVIRONMENT_IDENTIFIER, true) +} + +#[inline] +pub fn finish_process_security_environment_buffer<'a, 'b, A: ::flatbuffers::Allocator + 'a>( + fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + root: ::flatbuffers::WIPOffset>, +) { + fbb.finish(root, Some(PROCESS_SECURITY_ENVIRONMENT_IDENTIFIER)); +} + +#[inline] +pub fn finish_size_prefixed_process_security_environment_buffer< + 'a, + 'b, + A: ::flatbuffers::Allocator + 'a, +>( + fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + root: ::flatbuffers::WIPOffset>, +) { + fbb.finish_size_prefixed(root, Some(PROCESS_SECURITY_ENVIRONMENT_IDENTIFIER)); +} diff --git a/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/proxy_info_generated.rs b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/proxy_info_generated.rs new file mode 100644 index 000000000..2b97f1025 --- /dev/null +++ b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/proxy_info_generated.rs @@ -0,0 +1,137 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +pub enum ProxyInfoOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct ProxyInfo<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for ProxyInfo<'a> { + type Inner = ProxyInfo<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { + _tab: unsafe { ::flatbuffers::Table::new(buf, loc) }, + } + } +} + +impl<'a> ProxyInfo<'a> { + pub const VT_URL: ::flatbuffers::VOffsetT = 4; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + ProxyInfo { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: ::flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args ProxyInfoArgs<'args>, + ) -> ::flatbuffers::WIPOffset> { + let mut builder = ProxyInfoBuilder::new(_fbb); + if let Some(x) = args.url { + builder.add_url(x); + } + builder.finish() + } + + pub fn unpack(&self) -> ProxyInfoT { + let url = self.url().map(|x| alloc::string::ToString::to_string(x)); + ProxyInfoT { url } + } + + #[inline] + pub fn url(&self) -> Option<&'a str> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::<::flatbuffers::ForwardsUOffset<&str>>(ProxyInfo::VT_URL, None) + } + } +} + +impl ::flatbuffers::Verifiable for ProxyInfo<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::<::flatbuffers::ForwardsUOffset<&str>>("url", Self::VT_URL, false)? + .finish(); + Ok(()) + } +} +pub struct ProxyInfoArgs<'a> { + pub url: Option<::flatbuffers::WIPOffset<&'a str>>, +} +impl<'a> Default for ProxyInfoArgs<'a> { + #[inline] + fn default() -> Self { + ProxyInfoArgs { url: None } + } +} + +pub struct ProxyInfoBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> ProxyInfoBuilder<'a, 'b, A> { + #[inline] + pub fn add_url(&mut self, url: ::flatbuffers::WIPOffset<&'b str>) { + self.fbb_ + .push_slot_always::<::flatbuffers::WIPOffset<_>>(ProxyInfo::VT_URL, url); + } + #[inline] + pub fn new( + _fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + ) -> ProxyInfoBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + ProxyInfoBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for ProxyInfo<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("ProxyInfo"); + ds.field("url", &self.url()); + ds.finish() + } +} +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub struct ProxyInfoT { + pub url: Option, +} +impl Default for ProxyInfoT { + fn default() -> Self { + Self { url: None } + } +} +impl ProxyInfoT { + pub fn pack<'b, A: ::flatbuffers::Allocator + 'b>( + &self, + _fbb: &mut ::flatbuffers::FlatBufferBuilder<'b, A>, + ) -> ::flatbuffers::WIPOffset> { + let url = self.url.as_ref().map(|x| _fbb.create_string(x)); + ProxyInfo::create(_fbb, &ProxyInfoArgs { url }) + } +} diff --git a/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/schema_version_generated.rs b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/schema_version_generated.rs new file mode 100644 index 000000000..1b36e3d5b --- /dev/null +++ b/src/core/generated/process_security_environment_specification/src/process_security_environment_layout/schema_version_generated.rs @@ -0,0 +1,152 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +// struct SchemaVersion, aligned to 2 +#[repr(transparent)] +#[derive(Clone, Copy, PartialEq)] +pub struct SchemaVersion(pub [u8; 4]); +impl Default for SchemaVersion { + fn default() -> Self { + Self([0; 4]) + } +} +impl ::core::fmt::Debug for SchemaVersion { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + f.debug_struct("SchemaVersion") + .field("major", &self.major()) + .field("minor", &self.minor()) + .finish() + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for SchemaVersion {} +impl<'a> ::flatbuffers::Follow<'a> for SchemaVersion { + type Inner = &'a SchemaVersion; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + unsafe { <&'a SchemaVersion>::follow(buf, loc) } + } +} +impl<'a> ::flatbuffers::Follow<'a> for &'a SchemaVersion { + type Inner = &'a SchemaVersion; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + unsafe { ::flatbuffers::follow_cast_ref::(buf, loc) } + } +} +impl<'b> ::flatbuffers::Push for SchemaVersion { + type Output = SchemaVersion; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + let src = unsafe { + ::core::slice::from_raw_parts( + self as *const SchemaVersion as *const u8, + ::size(), + ) + }; + dst.copy_from_slice(src); + } + #[inline] + fn alignment() -> ::flatbuffers::PushAlignment { + ::flatbuffers::PushAlignment::new(2) + } +} + +impl<'a> ::flatbuffers::Verifiable for SchemaVersion { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.in_buffer::(pos) + } +} + +impl<'a> SchemaVersion { + #[allow(clippy::too_many_arguments)] + pub fn new(major: u16, minor: u16) -> Self { + let mut s = Self([0; 4]); + s.set_major(major); + s.set_minor(minor); + s + } + + pub fn major(&self) -> u16 { + let mut mem = + ::core::mem::MaybeUninit::<::Scalar>::uninit(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + ::flatbuffers::EndianScalar::from_little_endian(unsafe { + ::core::ptr::copy_nonoverlapping( + self.0[0..].as_ptr(), + mem.as_mut_ptr() as *mut u8, + ::core::mem::size_of::<::Scalar>(), + ); + mem.assume_init() + }) + } + + pub fn set_major(&mut self, x: u16) { + let x_le = ::flatbuffers::EndianScalar::to_little_endian(x); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + unsafe { + ::core::ptr::copy_nonoverlapping( + &x_le as *const _ as *const u8, + self.0[0..].as_mut_ptr(), + ::core::mem::size_of::<::Scalar>(), + ); + } + } + + pub fn minor(&self) -> u16 { + let mut mem = + ::core::mem::MaybeUninit::<::Scalar>::uninit(); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + ::flatbuffers::EndianScalar::from_little_endian(unsafe { + ::core::ptr::copy_nonoverlapping( + self.0[2..].as_ptr(), + mem.as_mut_ptr() as *mut u8, + ::core::mem::size_of::<::Scalar>(), + ); + mem.assume_init() + }) + } + + pub fn set_minor(&mut self, x: u16) { + let x_le = ::flatbuffers::EndianScalar::to_little_endian(x); + // Safety: + // Created from a valid Table for this object + // Which contains a valid value in this slot + unsafe { + ::core::ptr::copy_nonoverlapping( + &x_le as *const _ as *const u8, + self.0[2..].as_mut_ptr(), + ::core::mem::size_of::<::Scalar>(), + ); + } + } + + pub fn unpack(&self) -> SchemaVersionT { + SchemaVersionT { + major: self.major(), + minor: self.minor(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct SchemaVersionT { + pub major: u16, + pub minor: u16, +} +impl SchemaVersionT { + pub fn pack(&self) -> SchemaVersion { + SchemaVersion::new(self.major, self.minor) + } +} From 0c329dd394d58bf1505a3f87cef3700e3948c74b Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 30 Jul 2026 16:36:59 -0700 Subject: [PATCH 02/14] Harden V2 capture policy integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a8860a9-bb20-48ef-91a1-3da8e34b92fb --- docs/learning-mode/capabilities.md | 5 + docs/schema.md | 1 + .../common/src/base_container_runner.rs | 259 ++++++++++++------ .../appcontainer/common/src/dispatcher.rs | 33 +-- .../windows/examples/lm_probe.rs | 6 +- src/backends/learning_mode/windows/src/ffi.rs | 12 +- src/backends/learning_mode/windows/src/lib.rs | 6 +- .../learning_mode/windows/src/secenv.rs | 40 ++- 8 files changed, 251 insertions(+), 111 deletions(-) diff --git a/docs/learning-mode/capabilities.md b/docs/learning-mode/capabilities.md index 95a595e5f..f30b5a39b 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -126,6 +126,11 @@ ungranted access is handled while it is recorded: > the Windows process security-environment API used for capture does not expose > an LPAC token option, so MXC rejects that combination rather than silently > weakening the requested policy. +> +> `captureDenials` also cannot currently be combined with `network.proxy`. +> The V2 process security-environment proxy contract requires a separate proxy +> AppContainer peer identity; MXC rejects the combination until that peer is +> provisioned by the capture launch path. - `mode: "block"` (default) maps onto `learningModeLogging` (deny-and-record) — the app / user-configurable flow. diff --git a/docs/schema.md b/docs/schema.md index fb74d28d0..63eaccfe4 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -73,6 +73,7 @@ production configs and the dev schema when working on experimental features: // into the stem (denials..json) and the actual // path printed on stderr. Omit outputPath for a managed temp file. // captureDenials cannot be combined with leastPrivilege. + // captureDenials cannot currently be combined with network.proxy. }, "lxc": { // LXC-specific diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index 56c4f8fb9..495bed054 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -25,8 +25,8 @@ use learning_mode_windows::{ }; use windows::Win32::Foundation::{ - CloseHandle, GetLastError, SetHandleInformation, ERROR_CALL_NOT_IMPLEMENTED, - ERROR_NOT_SUPPORTED, E_NOTIMPL, HANDLE, HANDLE_FLAG_INHERIT, WAIT_OBJECT_0, WAIT_TIMEOUT, + CloseHandle, GetLastError, SetHandleInformation, ERROR_CALL_NOT_IMPLEMENTED, E_NOTIMPL, HANDLE, + HANDLE_FLAG_INHERIT, WAIT_OBJECT_0, WAIT_TIMEOUT, }; use windows::Win32::System::Console::{ GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, @@ -49,10 +49,9 @@ use crate::launch_diagnostics::{ use crate::proxy_coordinator::ProxyCoordinator; use crate::sandbox_tracking::{self, TrackingEntry}; use process_security_environment_spec::process_security_environment_layout::{ - finish_process_security_environment_buffer, NetworkPolicy as PsecNetworkPolicy, - NetworkPolicyArgs as PsecNetworkPolicyArgs, ProcessSecurityEnvironment, - ProcessSecurityEnvironmentArgs, ProxyInfo as PsecProxyInfo, ProxyInfoArgs as PsecProxyInfoArgs, - SchemaVersion, + finish_process_security_environment_buffer, EndpointPolicy, EndpointPolicyArgs, FilterAction, + NetworkPolicy as PsecNetworkPolicy, NetworkPolicyArgs as PsecNetworkPolicyArgs, + ProcessSecurityEnvironment, ProcessSecurityEnvironmentArgs, SchemaVersion, }; use sandbox_spec::base_container_layout::{ finish_sandbox_spec_buffer, proxy_info, proxy_infoArgs, IntegrityLevel, @@ -347,21 +346,9 @@ impl BaseContainerRunner { } } - fn cleanup_capture_prelaunch_failure( - &mut self, - request: &ExecutionRequest, - sid_string: &str, - logger: &mut Logger, - ) { - // This cannot be deferred to BaseContainerRunner::drop: the runner may - // outlive a failed spawn. - if request.lifecycle.destroy_on_exit { - // CaptureSession::begin receives the sandbox specification, not - // the later process identity. Its infallible environment close - // leaves only the pre-launch tracking entry to remove. - sandbox_tracking::remove_tracking_entry(sid_string, logger); - sandbox_tracking::unregister_ctrl_c_cleanup(); - } + fn cleanup_capture_begin_failure(&mut self, logger: &mut Logger) { + // CaptureSession owns and closes the PSEC environment. No legacy + // identity/tracking state is created for this path. self.proxy_coordinator.stop(logger); } @@ -668,26 +655,24 @@ impl BaseContainerRunner { let fs_read_only = create_string_vector(&mut builder, &request.policy.readonly_paths); let fs_deny = create_string_vector(&mut builder, &request.policy.denied_paths); - let network_policy = if request.policy.network_proxy.is_enabled() { - let proxy = request - .policy - .network_proxy - .address - .as_ref() - .map(|address| { - let url = builder.create_string(&address.to_url()); - PsecProxyInfo::create(&mut builder, &PsecProxyInfoArgs { url: Some(url) }) - }); - Some(PsecNetworkPolicy::create( - &mut builder, - &PsecNetworkPolicyArgs { - proxy, - ..Default::default() - }, - )) - } else { - None + let default_action = match request.policy.default_network_policy { + NetworkPolicy::Allow => FilterAction::allow, + NetworkPolicy::Block => FilterAction::deny, }; + let egress = EndpointPolicy::create( + &mut builder, + &EndpointPolicyArgs { + default_action, + ..Default::default() + }, + ); + let network_policy = Some(PsecNetworkPolicy::create( + &mut builder, + &PsecNetworkPolicyArgs { + egress: Some(egress), + ..Default::default() + }, + )); let ui_restrictions = crate::job_object::to_job_object_uilimit_mask( &wxc_common::ui_policy::resolve_ui_restrictions( @@ -932,12 +917,14 @@ impl BaseContainerRunner { let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: Build sandbox spec"); - // 1. Build the FlatBuffer sandbox spec from the request policy. - let spec_bytes = Self::build_sandbox_spec(&request); - - Self::log_sandbox_spec(&spec_bytes, logger); - let capture_denials = request.policy.capture_denials.clone(); + let spec_bytes = if capture_denials.is_none() { + let bytes = Self::build_sandbox_spec(&request); + Self::log_sandbox_spec(&bytes, logger); + Some(bytes) + } else { + None + }; if capture_denials.is_some() { let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: captureDenials"); } @@ -974,15 +961,21 @@ impl BaseContainerRunner { let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: Load API"); - // 2. Dynamically load the API from processmodel.dll. - let create_process_in_sandbox = match Self::load_api() { - Ok(f) => f, - Err(e) => return Err(ScriptResponse::error(&e)), + // The normal BaseContainer path uses the SBOX one-shot API. The V2 + // capture path uses only the process-security-environment APIs. + let create_process_in_sandbox = if capture_denials.is_none() { + let api = match Self::load_api() { + Ok(f) => f, + Err(e) => return Err(ScriptResponse::error(&e)), + }; + let _ = writeln!( + logger, + "loaded Experimental_CreateProcessInSandbox from processmodel.dll" + ); + Some(api) + } else { + None }; - let _ = writeln!( - logger, - "loaded Experimental_CreateProcessInSandbox from processmodel.dll" - ); let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: Launch process"); @@ -998,10 +991,15 @@ impl BaseContainerRunner { cwd_wide.as_ptr() }; - // Identity: when destroy_on_exit is true we generate a random ephemeral + let legacy_destroy_on_exit = capture_denials.is_none() && request.lifecycle.destroy_on_exit; + + // Identity applies only to the SBOX one-shot API. PSEC creates and owns + // its own AppContainer identity and profile. // identity so each sandbox gets a unique, cleanable AppContainer profile. // Otherwise we honour whatever the caller passed in (or the default). - let (identity, sid_string) = if request.lifecycle.destroy_on_exit { + let (identity, sid_string) = if capture_denials.is_some() { + ("".to_string(), String::new()) + } else if legacy_destroy_on_exit { let ephemeral = sandbox_tracking::generate_sandbox_identity(); let _ = writeln!( logger, @@ -1053,7 +1051,7 @@ impl BaseContainerRunner { // Register Ctrl+C handler early so cleanup runs if wxc-exec is interrupted // during or after the create call. - if request.lifecycle.destroy_on_exit { + if legacy_destroy_on_exit { sandbox_tracking::register_ctrl_c_cleanup( &identity, &sid_string, @@ -1312,7 +1310,7 @@ impl BaseContainerRunner { } else { FailurePhase::LaunchFailed }; - self.cleanup_capture_prelaunch_failure(&request, &sid_string, logger); + self.cleanup_capture_begin_failure(logger); return Err(ScriptResponse { exit_code: -1, error_message: msg.clone(), @@ -1367,7 +1365,7 @@ impl BaseContainerRunner { } else { FailurePhase::LaunchFailed }; - self.cleanup_capture_prelaunch_failure(&request, &sid_string, logger); + self.cleanup_capture_begin_failure(logger); return Err(ScriptResponse { exit_code: -1, error_message: msg.clone(), @@ -1402,13 +1400,29 @@ impl BaseContainerRunner { ) } } else { + let create_process_in_sandbox = match create_process_in_sandbox { + Some(api) => api, + None => { + return Err(ScriptResponse::error( + "internal error: SBOX launch API was not initialized", + )) + } + }; + let spec_bytes = match spec_bytes.as_deref() { + Some(bytes) => bytes, + None => { + return Err(ScriptResponse::error( + "internal error: SBOX specification was not initialized", + )) + } + }; let (success, error) = SandboxLaunchArgs { api: create_process_in_sandbox, command_line: &mut cmd_wide, current_directory: cwd_ptr, startup_info: &si, identity: &identity_wide, - sandbox_specification: &spec_bytes, + sandbox_specification: spec_bytes, no_window_flag, } .launch_with_environment_fallback( @@ -1435,8 +1449,8 @@ impl BaseContainerRunner { .take() .and_then(|session| session.finish(None).err()); if capture_denials.is_some() { - self.cleanup_capture_prelaunch_failure(&request, &sid_string, logger); - } else if request.lifecycle.destroy_on_exit { + self.cleanup_capture_begin_failure(logger); + } else if legacy_destroy_on_exit { // The OS may have created the AppContainer profile before // failing, so run the same cleanup logic used on normal exit. run_sandbox_cleanup( @@ -1549,8 +1563,8 @@ impl BaseContainerRunner { .take() .and_then(|session| session.finish(None).err()); if capture_denials.is_some() { - self.cleanup_capture_prelaunch_failure(&request, &sid_string, logger); - } else if request.lifecycle.destroy_on_exit { + self.cleanup_capture_begin_failure(logger); + } else if legacy_destroy_on_exit { run_sandbox_cleanup( &identity, &sid_string, @@ -1608,7 +1622,7 @@ impl BaseContainerRunner { stdout_read, stderr_read, timeout_ms: get_timeout_milliseconds(request.script_timeout), - destroy_on_exit: request.lifecycle.destroy_on_exit, + destroy_on_exit: legacy_destroy_on_exit, proxy_enabled: request.policy.network_proxy.is_enabled(), identity, sid_string, @@ -1656,28 +1670,71 @@ struct BaseChild { impl SandboxBackend for BaseContainerRunner { fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { - if request.policy.capture_denials.is_some() && request.policy.least_privilege_mode { + let capture_denials = request.policy.capture_denials.is_some(); + if capture_denials && request.policy.least_privilege_mode { return Err(ScriptResponse::error( "processContainer.captureDenials cannot be combined with \ processContainer.leastPrivilege because the Windows process \ security-environment contract does not support LPAC tokens", )); } + if capture_denials && request.policy.network_proxy.is_enabled() { + return Err(ScriptResponse::error( + "processContainer.captureDenials cannot be combined with network.proxy \ + until the V2 process-security-environment path can supply the required \ + proxy AppContainer peer identity", + )); + } // deniedPaths reaches the OS via the SandboxSpec `fs_deny` field, honored // only when the OS advertises SANDBOX_CAP_FS_DENY. The dispatcher only // routes deny here when supported; fail closed for direct callers. - if !request.policy.denied_paths.is_empty() - && !crate::fallback_detector::base_container_supports_deny_paths() - { - return Err(ScriptResponse::error( - wxc_common::error::DENIED_PATHS_FEATURE_DISABLED_MSG, - )); + if !request.policy.denied_paths.is_empty() { + let deny_supported = if capture_denials { + let api = SecurityEnvironmentApi::load().map_err(|error| ScriptResponse { + failure_phase: FailurePhase::BackendUnavailable, + ..ScriptResponse::error(&format!( + "process security-environment API unavailable: {error}" + )) + })?; + api.supports_deny_paths().map_err(|error| ScriptResponse { + failure_phase: FailurePhase::BackendUnavailable, + ..ScriptResponse::error(&format!( + "could not query process security-environment support: {error}" + )) + })? + } else { + crate::fallback_detector::base_container_supports_deny_paths() + }; + if !deny_supported { + return Err(ScriptResponse::error( + wxc_common::error::DENIED_PATHS_FEATURE_DISABLED_MSG, + )); + } } if !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty() { return Err(ScriptResponse::error( wxc_common::error::HOST_LISTS_NOT_SUPPORTED_MSG, )); } + if capture_denials { + return match (SecurityEnvironmentApi::load(), LearningModeApi::load()) { + (Ok(_), Ok(_)) => Ok(()), + (security_environment_api, learning_mode_api) => { + let detail = match (&security_environment_api, &learning_mode_api) { + (Err(error), _) => format!("security-environment API: {error}"), + (_, Err(error)) => format!("learning-mode trace API: {error}"), + _ => "unknown".to_string(), + }; + Err(ScriptResponse { + failure_phase: FailurePhase::BackendUnavailable, + ..ScriptResponse::error(&format!( + "captureDenials requires the official V2 APIs ({detail})" + )) + }) + } + }; + } + Self::is_base_container_api_present().map_err(|e| { let hint = format!( "BaseContainer API unavailable: {e}\n\ @@ -2511,7 +2568,10 @@ mod tests { #[test] fn capture_factory_injects_begin_failure() { let factory = Arc::new(FakeCaptureFactory { - begin_error: Some(("StartLearningModeTrace", windows::Win32::Foundation::E_FAIL.0)), + begin_error: Some(( + "StartLearningModeTrace", + windows::Win32::Foundation::E_FAIL.0, + )), finish_error: None, begin_calls: AtomicUsize::new(0), finish_calls: Arc::new(AtomicUsize::new(0)), @@ -2535,7 +2595,10 @@ mod tests { fn capture_factory_injects_finish_failure_once() { let factory = Arc::new(FakeCaptureFactory { begin_error: None, - finish_error: Some(("StopLearningModeTrace", windows::Win32::Foundation::E_FAIL.0)), + finish_error: Some(( + "StopLearningModeTrace", + windows::Win32::Foundation::E_FAIL.0, + )), begin_calls: AtomicUsize::new(0), finish_calls: Arc::new(AtomicUsize::new(0)), }); @@ -2647,10 +2710,6 @@ mod tests { request.policy.readwrite_paths = vec!["C:\\temp".into()]; request.policy.readonly_paths = vec!["C:\\Windows".into()]; request.policy.denied_paths = vec!["C:\\secret".into()]; - request.policy.network_proxy = ProxyConfig { - address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, - }; let bytes = BaseContainerRunner::build_process_security_environment_spec(&request); @@ -2672,12 +2731,29 @@ mod tests { spec.fs_deny().unwrap().iter().collect::>(), vec!["C:\\secret"] ); - assert_eq!( - spec.network_policy() - .and_then(|policy| policy.proxy()) - .and_then(|proxy| proxy.url()), - Some("http://127.0.0.1:8080") - ); + let egress = spec + .network_policy() + .and_then(|policy| policy.egress()) + .expect("PSEC must carry an explicit egress default"); + assert_eq!(egress.default_action(), FilterAction::deny); + assert!(egress.allow().is_none()); + assert!(egress.deny().is_none()); + } + + #[test] + fn build_process_security_environment_spec_preserves_allow_egress() { + let mut request = ExecutionRequest::default(); + request.policy.default_network_policy = NetworkPolicy::Allow; + + let bytes = BaseContainerRunner::build_process_security_environment_spec(&request); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + let egress = spec + .network_policy() + .and_then(|policy| policy.egress()) + .expect("PSEC must carry an explicit egress default"); + + assert_eq!(egress.default_action(), FilterAction::allow); + assert_eq!(spec.capabilities(), Some("internetClient")); } #[test] @@ -2919,4 +2995,21 @@ mod tests { assert!(error.error_message.contains("leastPrivilege")); } + + #[test] + fn validate_runner_rejects_capture_denials_with_proxy() { + let runner = BaseContainerRunner::new(); + let mut request = ExecutionRequest::default(); + request.policy.capture_denials = Some(Default::default()); + request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + + let error = runner + .validate(&request) + .expect_err("V2 capture proxy requires peer identity plumbing"); + + assert!(error.error_message.contains("network.proxy")); + } } diff --git a/src/backends/appcontainer/common/src/dispatcher.rs b/src/backends/appcontainer/common/src/dispatcher.rs index 24b9fda76..3f98229ef 100644 --- a/src/backends/appcontainer/common/src/dispatcher.rs +++ b/src/backends/appcontainer/common/src/dispatcher.rs @@ -338,13 +338,18 @@ fn select_backend_with_fallback( ), DispatchError, > { - let decision = fallback_detector::detect(&request.policy, /*prefer_bc=*/ true)?; - if request.policy.capture_denials.is_some() && decision.tier != IsolationTier::BaseContainer { - return Err(DispatchError::CaptureDenialsUnsupported { - tier: decision.tier, - }); + // captureDenials uses the official V2 process-security-environment API, + // independently of the legacy SBOX tier probe/fallback chain. + if request.policy.capture_denials.is_some() { + return Ok(( + SelectedBackend::BaseContainer(BaseContainerRunner::new()), + None, + IsolationTier::BaseContainer, + Vec::new(), + )); } + let decision = fallback_detector::detect(&request.policy, /*prefer_bc=*/ true)?; let (backend, dacl_manager): (SelectedBackend, Option) = match decision.tier { IsolationTier::BaseContainer => { // Tier 1 delegates filesystem-policy enforcement to @@ -689,22 +694,18 @@ mod tests { } #[test] - fn capture_denials_rejects_fallback_before_backend_or_dacl_setup() { + fn capture_denials_selects_v2_backend_without_legacy_fallback() { let _g = ForceTierGuard::set("appcontainer-dacl"); let (mut policy, _tmp) = policy_with_rw_temp(); policy.capture_denials = Some(Default::default()); let req = test_request(policy); - let error = match dispatch_with_fallback(&req) { - Ok(_) => panic!("capture fallback must be rejected"), - Err(error) => error, - }; - assert!(matches!( - error, - DispatchError::CaptureDenialsUnsupported { - tier: IsolationTier::AppContainerDacl - } - )); + let dispatched = dispatch_with_fallback(&req).expect("V2 backend should be selected"); + assert!(matches!(dispatched.tier, IsolationTier::BaseContainer)); + assert!( + !dispatched.has_dacl_guard(), + "V2 capture must never apply legacy host DACLs" + ); } #[test] fn dispatch_fallback_disabled_errors() { diff --git a/src/backends/learning_mode/windows/examples/lm_probe.rs b/src/backends/learning_mode/windows/examples/lm_probe.rs index c76a019c8..8b983a7c9 100644 --- a/src/backends/learning_mode/windows/examples/lm_probe.rs +++ b/src/backends/learning_mode/windows/examples/lm_probe.rs @@ -7,9 +7,8 @@ //! exports (`StartLearningModeTrace` / `StopLearningModeTrace` / //! `CloseLearningModeTrace`) and the 2-phase security-environment exports //! (`CreateProcessSecurityEnvironment` / `CloseProcessSecurityEnvironment`), -//! reporting the exact resolved name for each (plain vs `Experimental_`). Intended to -//! be run on a feature-enabled Windows build to confirm the runtime FFI resolves -//! against the real API. +//! reporting each official export that resolves. Intended to be run on a +//! feature-enabled Windows build to confirm the runtime FFI resolves against the real API. //! //! ```text //! cargo run -p learning_mode_windows --example lm_probe @@ -34,6 +33,7 @@ fn run_probe() -> i32 { let report = learning_mode_windows::probe_security_environment_exports(); println!(" create export = {:?}", report.create); + println!(" query support = {:?}", report.query_support); println!(" close export = {:?}", report.close); match learning_mode_windows::SecurityEnvironmentApi::load() { diff --git a/src/backends/learning_mode/windows/src/ffi.rs b/src/backends/learning_mode/windows/src/ffi.rs index 3f907468e..f18490994 100644 --- a/src/backends/learning_mode/windows/src/ffi.rs +++ b/src/backends/learning_mode/windows/src/ffi.rs @@ -3,9 +3,9 @@ //! Windows runtime FFI for the `processmodel.dll` Learning Mode trace exports. //! -//! The three exports are resolved once via `LoadLibraryExW(LOAD_LIBRARY_SEARCH_SYSTEM32)` -//! and `GetProcAddress`. As with the sibling `Experimental_CreateProcessInSandbox` -//! adapter, `processmodel.dll` is intentionally never freed: it is a system DLL that +//! The three official V2 exports are resolved via +//! `LoadLibraryExW(LOAD_LIBRARY_SEARCH_SYSTEM32)` and `GetProcAddress`. +//! `processmodel.dll` is intentionally never freed: it is a system DLL that //! stays resident for the process lifetime, so the module handle is used only to //! resolve exports and then dropped without `FreeLibrary`. @@ -168,6 +168,12 @@ impl LearningModeApi { code: result.0, }); } + if trace.0.is_null() { + return Err(LearningModeError::HResultCall { + function: "StartLearningModeTrace", + code: windows::Win32::Foundation::E_UNEXPECTED.0, + }); + } Ok(LearningModeTraceHandle::new(trace, self.close)) } diff --git a/src/backends/learning_mode/windows/src/lib.rs b/src/backends/learning_mode/windows/src/lib.rs index de2480583..da89dba00 100644 --- a/src/backends/learning_mode/windows/src/lib.rs +++ b/src/backends/learning_mode/windows/src/lib.rs @@ -5,8 +5,7 @@ //! **Learning Mode trace API** exported by `processmodel.dll`. //! //! Supported Windows builds expose a privileged, per-client learning-mode -//! ETW trace behind three flat C exports in `processmodel.dll` — the same system DLL -//! the BaseContainer backend already loads for `Experimental_CreateProcessInSandbox`: +//! ETW trace behind three official flat C exports in `processmodel.dll`: //! //! ```c //! HRESULT StartLearningModeTrace(HPROCESS_SECURITY_ENVIRONMENT environment, HLEARNINGMODE_TRACE* trace); @@ -23,8 +22,7 @@ //! //! Because the exports only exist on feature-enabled OS builds, this crate resolves //! them at runtime via `LoadLibrary`/`GetProcAddress` behind the [`is_learning_mode_api_available`] -//! capability probe, mirroring the existing `Experimental_CreateProcessInSandbox` -//! adapter. The crate compiles on every platform: the capability probe returns +//! capability probe. The crate compiles on every platform: the capability probe returns //! `false` on non-Windows targets, while the loader and capture lifecycle types are //! exported only on Windows. diff --git a/src/backends/learning_mode/windows/src/secenv.rs b/src/backends/learning_mode/windows/src/secenv.rs index f826732ae..12a86911d 100644 --- a/src/backends/learning_mode/windows/src/secenv.rs +++ b/src/backends/learning_mode/windows/src/secenv.rs @@ -70,6 +70,10 @@ type PfnCreateProcessSecurityEnvironment = unsafe extern "system" fn( process_security_environment: *mut HANDLE, ) -> HRESULT; +/// `HRESULT QueryProcessSecurityEnvironmentSupport(UINT64* supportFlags)`. +type PfnQueryProcessSecurityEnvironmentSupport = + unsafe extern "system" fn(support_flags: *mut u64) -> HRESULT; + /// `void CloseProcessSecurityEnvironment(HPROCESS_SECURITY_ENVIRONMENT processSecurityEnvironment)`. type PfnCloseProcessSecurityEnvironment = unsafe extern "system" fn(process_security_environment: HANDLE); @@ -287,6 +291,8 @@ impl Drop for SecurityEnvironmentStartupInfo { pub struct SecurityEnvironmentExportReport { /// Resolved name of the create export, if present. pub create: Option<&'static str>, + /// Resolved name of the support-query export, if present. + pub query_support: Option<&'static str>, /// Resolved name of the close export, if present. pub close: Option<&'static str>, } @@ -295,17 +301,19 @@ impl SecurityEnvironmentExportReport { /// `true` only when every export required for the 2-phase launch resolved. #[must_use] pub fn is_complete(&self) -> bool { - self.create.is_some() && self.close.is_some() + self.create.is_some() && self.query_support.is_some() && self.close.is_some() } } const CREATE_NAMES: &[&core::ffi::CStr] = &[c"CreateProcessSecurityEnvironment"]; +const QUERY_SUPPORT_NAMES: &[&core::ffi::CStr] = &[c"QueryProcessSecurityEnvironmentSupport"]; const CLOSE_NAMES: &[&core::ffi::CStr] = &[c"CloseProcessSecurityEnvironment"]; /// Resolved process security-environment exports from `processmodel.dll`. #[derive(Clone, Copy)] pub struct SecurityEnvironmentApi { create: PfnCreateProcessSecurityEnvironment, + query_support: PfnQueryProcessSecurityEnvironmentSupport, close: PfnCloseProcessSecurityEnvironment, } @@ -313,6 +321,7 @@ impl std::fmt::Debug for SecurityEnvironmentApi { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SecurityEnvironmentApi") .field("create", &(self.create as *const ())) + .field("query_support", &(self.query_support as *const ())) .field("close", &(self.close as *const ())) .finish() } @@ -337,6 +346,7 @@ impl SecurityEnvironmentApi { .map_err(|e| LearningModeError::DllLoad(e.to_string()))?; let create_proc = resolve_any(hmodule, CREATE_NAMES)?; + let query_support_proc = resolve_any(hmodule, QUERY_SUPPORT_NAMES)?; let close_proc = resolve_any(hmodule, CLOSE_NAMES)?; Ok(Self { @@ -344,6 +354,10 @@ impl SecurityEnvironmentApi { unsafe extern "system" fn() -> isize, PfnCreateProcessSecurityEnvironment, >(create_proc), + query_support: std::mem::transmute::< + unsafe extern "system" fn() -> isize, + PfnQueryProcessSecurityEnvironmentSupport, + >(query_support_proc), close: std::mem::transmute::< unsafe extern "system" fn() -> isize, PfnCloseProcessSecurityEnvironment, @@ -352,6 +366,22 @@ impl SecurityEnvironmentApi { } } + /// Whether the official V2 API supports native deny paths. + pub fn supports_deny_paths(&self) -> Result { + const PSE_SUPPORT_FS_DENY: u64 = 0x0000_0000_0000_0001; + let mut support_flags = 0u64; + // SAFETY: `query_support` matches the official V2 declaration and + // `support_flags` is a valid out-pointer. + let result = unsafe { (self.query_support)(&mut support_flags) }; + if result.is_err() { + return Err(LearningModeError::HResultCall { + function: "QueryProcessSecurityEnvironmentSupport", + code: result.0, + }); + } + Ok(support_flags & PSE_SUPPORT_FS_DENY != 0) + } + /// Create a process security environment from a PSEC FlatBuffer /// blob. `flags` is currently always [`PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE`]. /// @@ -388,12 +418,17 @@ impl SecurityEnvironmentApi { code: result.0, }); } + if env.0.is_null() { + return Err(LearningModeError::HResultCall { + function: "CreateProcessSecurityEnvironment", + code: windows::Win32::Foundation::E_UNEXPECTED.0, + }); + } Ok(ProcessSecurityEnvironment { handle: env, close: self.close, }) } - } /// Resolve the first name in `names` that is present in `hmodule`. @@ -451,6 +486,7 @@ pub fn probe_security_environment_exports() -> SecurityEnvironmentExportReport { unsafe { SecurityEnvironmentExportReport { create: first_present(hmodule, CREATE_NAMES), + query_support: first_present(hmodule, QUERY_SUPPORT_NAMES), close: first_present(hmodule, CLOSE_NAMES), } } From bd2ec2cebddedab4bef293379526495ee60879ac Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 3 Aug 2026 12:52:54 -0700 Subject: [PATCH 03/14] Clarify V2 security environment docs Document the official-only exports and attribute-based process launch after rebasing the V2 contract changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a8860a9-bb20-48ef-91a1-3da8e34b92fb --- src/backends/learning_mode/windows/src/secenv.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/backends/learning_mode/windows/src/secenv.rs b/src/backends/learning_mode/windows/src/secenv.rs index 12a86911d..8f65e88eb 100644 --- a/src/backends/learning_mode/windows/src/secenv.rs +++ b/src/backends/learning_mode/windows/src/secenv.rs @@ -10,8 +10,8 @@ //! resolves it to the target AppContainer SID server-side). Neither of MXC's existing //! launch paths yields that handle — classic AppContainer uses `CreateProcess` + //! `SECURITY_CAPABILITIES`, and BaseContainer uses the one-shot RPC-brokered -//! `Experimental_CreateProcessInSandbox`. To capture denials, MXC adopts the flat -//! 2-phase model exported by the same `processmodel.dll`: +//! `Experimental_CreateProcessInSandbox`. To capture denials, MXC uses the +//! official process security-environment model exported by `processmodel.dll`: //! //! ```c //! HRESULT CreateProcessSecurityEnvironment( @@ -284,9 +284,7 @@ impl Drop for SecurityEnvironmentStartupInfo { } } -/// Which candidate export name resolved for each function on this machine — a -/// diagnostic used by the capability probe to report the exact live surface (plain vs -/// `Experimental_`). +/// Which official export resolved for each function on this machine. #[derive(Debug, Clone, Copy, Default)] pub struct SecurityEnvironmentExportReport { /// Resolved name of the create export, if present. @@ -468,9 +466,8 @@ fn last_error() -> u32 { unsafe { GetLastError().0 } } -/// Diagnostic probe reporting which security-environment export name resolved for each -/// function (plain vs `Experimental_`). Returns an all-`None` report if the DLL itself -/// cannot be loaded. +/// Diagnostic probe reporting which official security-environment exports +/// resolved. Returns an all-`None` report if the DLL itself cannot be loaded. #[must_use] pub fn probe_security_environment_exports() -> SecurityEnvironmentExportReport { let dll = string_util::to_wide(PROCESSMODEL_DLL); From 1a8613f68b0bb1d440767166d5aa842bd0a351b1 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 3 Aug 2026 15:23:56 -0700 Subject: [PATCH 04/14] Refresh lm_analyze documentation Clarify that captureDenials now decodes ETLs and returns output metadata automatically, while lm_analyze remains a developer diagnostic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a8860a9-bb20-48ef-91a1-3da8e34b92fb --- .../learning_mode/windows/examples/lm_analyze.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/backends/learning_mode/windows/examples/lm_analyze.rs b/src/backends/learning_mode/windows/examples/lm_analyze.rs index 84185b156..078745a75 100644 --- a/src/backends/learning_mode/windows/examples/lm_analyze.rs +++ b/src/backends/learning_mode/windows/examples/lm_analyze.rs @@ -4,10 +4,11 @@ //! Decode a sealed learning-mode `.etl` into the captureDenials JSON //! output document, or dump its raw ETW events for schema discovery. //! -//! This is a developer diagnostic for inspecting captured traces manually. -//! End users and SDK agents do not invoke it: the BaseContainer runner seals -//! the trace and reports its path, while this example performs the manual -//! analysis until runner integration consumes the trace automatically. +//! This is a developer diagnostic for inspecting saved traces independently +//! of the production pipeline. Normal `captureDenials` execution seals and +//! decodes its internal ETL automatically, writes the JSON denials document, +//! deletes the ETL, and returns structured output metadata. End users and SDK +//! callers therefore do not invoke this example. //! //! Usage: //! From b21163a83bbfe5242765a2cb245f440329c1e130 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 3 Aug 2026 15:46:27 -0700 Subject: [PATCH 05/14] Address PR 739 review feedback Use a V2-specific denied-path capability error and document the complete process security-environment probe surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a8860a9-bb20-48ef-91a1-3da8e34b92fb --- .../common/src/base_container_runner.rs | 32 +++++++++++++++---- .../windows/examples/lm_probe.rs | 4 ++- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index 495bed054..bfa958338 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -226,6 +226,11 @@ const SANDBOX_CAP_CREATE_PROCESS_IN_SANDBOX: u64 = 0x0000_0000_0000_0001; const SANDBOX_CAP_FS_DENY: u64 = 0x0000_0000_0000_0002; const CAPTURE_API_AVAILABLE_LOG: &str = "captureDenials: learning-mode trace API available (processmodel.dll)"; +const CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG: &str = + "processContainer.captureDenials with filesystem.deniedPaths requires \ + QueryProcessSecurityEnvironmentSupport to advertise PSE_SUPPORT_FS_DENY; \ + this OS build does not support that policy, and capture cannot fall back \ + to AppContainer or host-DACL enforcement"; const CREATE_PROCESS_IN_SANDBOX_API: &str = "Experimental_CreateProcessInSandbox"; const CREATE_PROCESS_IN_SECURITY_ENVIRONMENT_API: &str = "CreateProcessW(PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT)"; @@ -1685,9 +1690,9 @@ impl SandboxBackend for BaseContainerRunner { proxy AppContainer peer identity", )); } - // deniedPaths reaches the OS via the SandboxSpec `fs_deny` field, honored - // only when the OS advertises SANDBOX_CAP_FS_DENY. The dispatcher only - // routes deny here when supported; fail closed for direct callers. + // deniedPaths reaches ordinary BaseContainer through SBOX and capture + // through PSEC. Each path has a distinct support query; fail closed + // rather than silently dropping the deny policy. if !request.policy.denied_paths.is_empty() { let deny_supported = if capture_denials { let api = SecurityEnvironmentApi::load().map_err(|error| ScriptResponse { @@ -1706,9 +1711,14 @@ impl SandboxBackend for BaseContainerRunner { crate::fallback_detector::base_container_supports_deny_paths() }; if !deny_supported { - return Err(ScriptResponse::error( - wxc_common::error::DENIED_PATHS_FEATURE_DISABLED_MSG, - )); + return Err(if capture_denials { + ScriptResponse { + failure_phase: FailurePhase::BackendUnavailable, + ..ScriptResponse::error(CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG) + } + } else { + ScriptResponse::error(wxc_common::error::DENIED_PATHS_FEATURE_DISABLED_MSG) + }); } } if !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty() { @@ -2982,6 +2992,16 @@ mod tests { } } + #[test] + fn capture_denied_paths_error_names_v2_capability() { + assert!( + CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG.contains("QueryProcessSecurityEnvironmentSupport") + ); + assert!(CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG.contains("PSE_SUPPORT_FS_DENY")); + assert!(!CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG.contains("Experimental_QuerySandboxSupport")); + assert!(CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG.contains("cannot fall back to AppContainer")); + } + #[test] fn validate_runner_rejects_capture_denials_with_least_privilege() { let runner = BaseContainerRunner::new(); diff --git a/src/backends/learning_mode/windows/examples/lm_probe.rs b/src/backends/learning_mode/windows/examples/lm_probe.rs index 8b983a7c9..125255355 100644 --- a/src/backends/learning_mode/windows/examples/lm_probe.rs +++ b/src/backends/learning_mode/windows/examples/lm_probe.rs @@ -6,7 +6,9 @@ //! Prints whether `processmodel.dll` on this machine exposes the Learning Mode trace //! exports (`StartLearningModeTrace` / `StopLearningModeTrace` / //! `CloseLearningModeTrace`) and the 2-phase security-environment exports -//! (`CreateProcessSecurityEnvironment` / `CloseProcessSecurityEnvironment`), +//! (`CreateProcessSecurityEnvironment` / +//! `QueryProcessSecurityEnvironmentSupport` / +//! `CloseProcessSecurityEnvironment`), //! reporting each official export that resolves. Intended to be run on a //! feature-enabled Windows build to confirm the runtime FFI resolves against the real API. //! From dfca3cc4e7aedc316618d81a6197e61461191855 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 3 Aug 2026 16:11:52 -0700 Subject: [PATCH 06/14] Retry transient trace delivery failures Use the V2 non-consuming Stop contract for three bounded attempts on transient output contention while preserving permanent and exhausted HRESULT failures before exactly-once close. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a8860a9-bb20-48ef-91a1-3da8e34b92fb --- src/backends/learning_mode/windows/src/ffi.rs | 126 +++++++++++++++++- .../learning_mode/windows/src/lifecycle.rs | 13 +- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/src/backends/learning_mode/windows/src/ffi.rs b/src/backends/learning_mode/windows/src/ffi.rs index f18490994..01c2fb857 100644 --- a/src/backends/learning_mode/windows/src/ffi.rs +++ b/src/backends/learning_mode/windows/src/ffi.rs @@ -11,8 +11,12 @@ use std::path::Path; use std::ptr; +use std::time::Duration; -use windows::Win32::Foundation::{GetLastError, HANDLE, HMODULE}; +use windows::Win32::Foundation::{ + GetLastError, ERROR_BUSY, ERROR_LOCK_VIOLATION, ERROR_RETRY, ERROR_SHARING_VIOLATION, HANDLE, + HMODULE, +}; use windows::Win32::System::LibraryLoader::{ GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32, }; @@ -111,6 +115,9 @@ impl std::fmt::Debug for LearningModeApi { } impl LearningModeApi { + const STOP_DELIVERY_ATTEMPTS: usize = 3; + const STOP_RETRY_DELAYS: [Duration; 2] = [Duration::from_millis(25), Duration::from_millis(75)]; + /// Load `processmodel.dll` and resolve the Learning Mode trace exports. /// /// # Errors @@ -195,6 +202,28 @@ impl LearningModeApi { self.stop_trace_encoded(trace, wide_path.as_deref()) } + /// Stop and deliver the trace, retrying only transient output-delivery + /// failures. The trace remains live throughout the attempts and is still + /// owned by the caller when this method returns. + pub(crate) fn stop_trace_with_retry( + &self, + trace: &LearningModeTraceHandle, + output_path: Option<&Path>, + ) -> Result<(), LearningModeError> { + for attempt in 0..Self::STOP_DELIVERY_ATTEMPTS { + match self.stop_trace(trace, output_path) { + Err(error) + if attempt + 1 < Self::STOP_DELIVERY_ATTEMPTS + && is_retryable_stop_error(&error) => + { + std::thread::sleep(Self::STOP_RETRY_DELAYS[attempt]); + } + result => return result, + } + } + unreachable!("STOP_DELIVERY_ATTEMPTS is non-zero") + } + fn stop_trace_encoded( &self, trace: &LearningModeTraceHandle, @@ -217,6 +246,25 @@ impl LearningModeApi { } } +fn is_retryable_stop_error(error: &LearningModeError) -> bool { + let LearningModeError::HResultCall { + function: "StopLearningModeTrace", + code, + } = error + else { + return false; + }; + + [ + ERROR_SHARING_VIOLATION, + ERROR_LOCK_VIOLATION, + ERROR_BUSY, + ERROR_RETRY, + ] + .into_iter() + .any(|win32| *code == HRESULT::from_win32(win32.0).0) +} + fn encode_output_path(output_path: Option<&Path>) -> Result>, LearningModeError> { output_path .map(|path| { @@ -281,6 +329,8 @@ mod tests { static TEST_LOCK: Mutex<()> = Mutex::new(()); static START_RESULT: AtomicI32 = AtomicI32::new(S_OK.0); static STOP_RESULT: AtomicI32 = AtomicI32::new(S_OK.0); + static STOP_FAILURE_RESULT: AtomicI32 = AtomicI32::new(E_FAIL.0); + static STOP_FAILURES_REMAINING: AtomicUsize = AtomicUsize::new(0); static STOP_CALLS: AtomicUsize = AtomicUsize::new(0); static CLOSE_CALLS: AtomicUsize = AtomicUsize::new(0); @@ -296,6 +346,14 @@ mod tests { unsafe extern "system" fn fake_stop(_: HANDLE, _: *const u16) -> HRESULT { STOP_CALLS.fetch_add(1, Ordering::SeqCst); + if STOP_FAILURES_REMAINING + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return HRESULT(STOP_FAILURE_RESULT.load(Ordering::SeqCst)); + } HRESULT(STOP_RESULT.load(Ordering::SeqCst)) } @@ -318,6 +376,8 @@ mod tests { fn reset_fakes() { START_RESULT.store(S_OK.0, Ordering::SeqCst); STOP_RESULT.store(S_OK.0, Ordering::SeqCst); + STOP_FAILURE_RESULT.store(E_FAIL.0, Ordering::SeqCst); + STOP_FAILURES_REMAINING.store(0, Ordering::SeqCst); STOP_CALLS.store(0, Ordering::SeqCst); CLOSE_CALLS.store(0, Ordering::SeqCst); } @@ -417,6 +477,70 @@ mod tests { assert_eq!(CLOSE_CALLS.load(Ordering::SeqCst), 1); } + #[test] + fn transient_stop_failure_is_retried() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_fakes(); + STOP_FAILURE_RESULT.store( + HRESULT::from_win32(ERROR_SHARING_VIOLATION.0).0, + Ordering::SeqCst, + ); + STOP_FAILURES_REMAINING.store(2, Ordering::SeqCst); + let api = fake_api(); + let trace = unsafe { api.start_trace(fake_environment()).unwrap() }; + + api.stop_trace_with_retry(&trace, None).unwrap(); + + assert_eq!(STOP_CALLS.load(Ordering::SeqCst), 3); + trace.close(); + assert_eq!(CLOSE_CALLS.load(Ordering::SeqCst), 1); + } + + #[test] + fn permanent_stop_failure_is_not_retried() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_fakes(); + STOP_RESULT.store(E_FAIL.0, Ordering::SeqCst); + let api = fake_api(); + let trace = unsafe { api.start_trace(fake_environment()).unwrap() }; + + let error = api.stop_trace_with_retry(&trace, None).unwrap_err(); + + assert!(matches!( + error, + LearningModeError::HResultCall { + function: "StopLearningModeTrace", + code + } if code == E_FAIL.0 + )); + assert_eq!(STOP_CALLS.load(Ordering::SeqCst), 1); + } + + #[test] + fn exhausted_transient_stop_retries_preserve_hresult() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_fakes(); + let retry_hresult = HRESULT::from_win32(ERROR_LOCK_VIOLATION.0).0; + STOP_FAILURE_RESULT.store(retry_hresult, Ordering::SeqCst); + STOP_FAILURES_REMAINING.store(3, Ordering::SeqCst); + let api = fake_api(); + let trace = unsafe { api.start_trace(fake_environment()).unwrap() }; + + let error = api.stop_trace_with_retry(&trace, None).unwrap_err(); + + assert!(matches!( + error, + LearningModeError::HResultCall { + function: "StopLearningModeTrace", + code + } if code == retry_hresult + )); + assert_eq!( + STOP_CALLS.load(Ordering::SeqCst), + LearningModeApi::STOP_DELIVERY_ATTEMPTS + ); + } + #[test] fn failed_start_preserves_hresult_and_does_not_close() { let _guard = TEST_LOCK.lock().unwrap(); diff --git a/src/backends/learning_mode/windows/src/lifecycle.rs b/src/backends/learning_mode/windows/src/lifecycle.rs index ff8ad06bf..c51a48686 100644 --- a/src/backends/learning_mode/windows/src/lifecycle.rs +++ b/src/backends/learning_mode/windows/src/lifecycle.rs @@ -14,7 +14,8 @@ //! `CreateProcessW` (**runner's job**; the session exposes the handle via //! [`CaptureSession::environment`]) //! 4. wait for the child to exit -//! 5. `StopLearningModeTrace(trace, outputPath)` → sealed ETL (retryable delivery) +//! 5. `StopLearningModeTrace(trace, outputPath)` → sealed ETL (bounded retries +//! for transient delivery failures) //! 6. `CloseLearningModeTrace(trace)` → release broker state and staged ETL //! 7. `CloseProcessSecurityEnvironment(env)` → teardown //! @@ -101,15 +102,17 @@ impl CaptureSession { } } - /// Stop the trace and deliver it to `output_path` (or skip delivery when `None`), - /// close the trace, then close the security environment. Call **after** the child - /// has exited. + /// Stop the trace and deliver it to `output_path` (or skip delivery when + /// `None`), retry transient delivery failures, close the trace, then close + /// the security environment. Call **after** the child has exited. /// /// # Errors /// - [`LearningModeError::HResultCall`] from `StopLearningModeTrace`. pub fn finish(mut self, output_path: Option<&Path>) -> Result<(), LearningModeError> { let stop_result = match self.trace.as_ref() { - Some(trace) => self.learning_mode_api.stop_trace(trace, output_path), + Some(trace) => self + .learning_mode_api + .stop_trace_with_retry(trace, output_path), None => Ok(()), }; if let Some(trace) = self.trace.take() { From 5f4237be2e2f2d43dbff3e9d78708777271fbca9 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 3 Aug 2026 19:49:23 -0700 Subject: [PATCH 07/14] Address comprehensive V2 review feedback Add host-independent lifecycle and fail-closed tests, memoize V2 capability discovery, document host requirements, regenerate schema surfaces, and pin/provenance-gate the generated PSEC contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a8860a9-bb20-48ef-91a1-3da8e34b92fb --- .github/workflows/Versioning.Checks.Job.yml | 3 + docs/learning-mode/capabilities.md | 20 +- docs/process-container/os-version-support.md | 24 ++ ...ProcessSecurityEnvironment.provenance.toml | 52 +++ schemas/dev/mxc-config.schema.0.8.0-dev.json | 4 +- scripts/versioning/check-psec-codegen.js | 367 ++++++++++++++++++ sdk/node/src/generated/wire.ts | 4 +- .../common/src/base_container_runner.rs | 237 +++++++++-- src/backends/learning_mode/windows/src/ffi.rs | 167 +++++++- src/backends/learning_mode/windows/src/lib.rs | 9 +- .../learning_mode/windows/src/lifecycle.rs | 204 ++++++++++ .../learning_mode/windows/src/secenv.rs | 220 ++++++++++- .../Cargo.toml | 4 + .../README.md | 44 ++- .../regenerate.ps1 | 70 +++- src/core/wxc_common/src/wire.rs | 10 +- 16 files changed, 1370 insertions(+), 69 deletions(-) create mode 100644 external/windows-sdk/ProcessSecurityEnvironment.provenance.toml create mode 100644 scripts/versioning/check-psec-codegen.js diff --git a/.github/workflows/Versioning.Checks.Job.yml b/.github/workflows/Versioning.Checks.Job.yml index eafefcd3e..9d6ecf5fe 100644 --- a/.github/workflows/Versioning.Checks.Job.yml +++ b/.github/workflows/Versioning.Checks.Job.yml @@ -33,6 +33,9 @@ jobs: - name: Check schema is in sync with the Rust wire model (codegen) run: node scripts/versioning/check-schema-codegen.js + - name: Check PSEC generated contract (provenance + drift) + run: node scripts/versioning/check-psec-codegen.js + - name: Check SDK wire types are in sync with the Rust wire model (codegen) run: node scripts/versioning/check-sdk-types-codegen.js diff --git a/docs/learning-mode/capabilities.md b/docs/learning-mode/capabilities.md index f30b5a39b..0db29ffe9 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -118,9 +118,17 @@ surfacing the resulting denials to the caller. Its `mode` selects how each ungranted access is handled while it is recorded: > **Host requirement.** `captureDenials` requires a feature-enabled Windows -> build exposing the BaseContainer security-environment and Learning Mode APIs. -> It is not supported by the AppContainer fallback tiers; unsupported hosts -> return `backend_unavailable`. +> build exposing the complete official V2 API set: +> `StartLearningModeTrace`, `StopLearningModeTrace`, +> `CloseLearningModeTrace`, `CreateProcessSecurityEnvironment`, +> `QueryProcessSecurityEnvironmentSupport`, and +> `CloseProcessSecurityEnvironment`. It is not supported by the AppContainer +> fallback tiers; unsupported hosts return `backend_unavailable`. +> +> Internal validation confirmed that build `26657.1002` exposes only the +> incompatible earlier contract and is rejected, while build `26663.1000` +> exposes the complete V2 contract. These are validation points, not a public +> Windows release-floor commitment; callers should rely on the runtime probe. > > `captureDenials` cannot be combined with `processContainer.leastPrivilege`; > the Windows process security-environment API used for capture does not expose @@ -131,6 +139,12 @@ ungranted access is handled while it is recorded: > The V2 process security-environment proxy contract requires a separate proxy > AppContainer peer identity; MXC rejects the combination until that peer is > provisioned by the capture launch path. +> +> `filesystem.deniedPaths` requires +> `QueryProcessSecurityEnvironmentSupport` to advertise +> `PSE_SUPPORT_FS_DENY`. When the bit is absent, capture fails as +> `backend_unavailable`; it cannot fall back to AppContainer or host-DACL +> enforcement. - `mode: "block"` (default) maps onto `learningModeLogging` (deny-and-record) — the app / user-configurable flow. diff --git a/docs/process-container/os-version-support.md b/docs/process-container/os-version-support.md index 7df7abd30..0103755ff 100644 --- a/docs/process-container/os-version-support.md +++ b/docs/process-container/os-version-support.md @@ -49,6 +49,30 @@ available bounds what policy can be enforced. - **T3 (AppContainer + DACL)** is the universal fallback and enforces filesystem policy via host path ACEs on every release. +## Learning Mode denial capture + +`processContainer.captureDenials` uses a separate V2 process +security-environment path rather than the T1/T2/T3 fallback chain. The host +must expose the complete official V2 export set: + +- `StartLearningModeTrace` +- `StopLearningModeTrace` +- `CloseLearningModeTrace` +- `CreateProcessSecurityEnvironment` +- `QueryProcessSecurityEnvironmentSupport` +- `CloseProcessSecurityEnvironment` + +Unsupported or earlier-contract hosts fail as `backend_unavailable`; capture +never falls back to AppContainer or host-DACL enforcement. Internal validation +confirmed the earlier contract on build `26657.1002` is rejected and the full +V2 contract on build `26663.1000` is accepted. These builds are validation +points, not a public release-floor commitment; runtime export probing is the +source of truth. + +Capture is incompatible with `processContainer.leastPrivilege` and +`network.proxy`. `filesystem.deniedPaths` is accepted only when +`QueryProcessSecurityEnvironmentSupport` advertises `PSE_SUPPORT_FS_DENY`. + ## Filesystem policy | Aspect | 23H2 | 24H2 | 25H2 | 25H2+ | diff --git a/external/windows-sdk/ProcessSecurityEnvironment.provenance.toml b/external/windows-sdk/ProcessSecurityEnvironment.provenance.toml new file mode 100644 index 000000000..c225dde47 --- /dev/null +++ b/external/windows-sdk/ProcessSecurityEnvironment.provenance.toml @@ -0,0 +1,52 @@ +# Provenance for the vendored ProcessSecurityEnvironment FlatBuffers schema. +# +# This file is the single source of truth for the pinned regeneration toolchain +# and the authoritative hash of the vendored `ProcessSecurityEnvironment.fbs`. +# It is consumed by: +# * src/core/generated/process_security_environment_specification/regenerate.ps1 +# (validates the schema hash + pins the exact flatc version before generating) +# * scripts/versioning/check-psec-codegen.js +# (CI drift gate — verifies the committed schema + generated crate) +# +# Update this file whenever the vendored schema is refreshed from the OS source, +# then regenerate the bindings (see the crate README). + +[source] +# The schema originates from the internal Microsoft Windows OS repository and is +# not publicly redistributable. Only the schema text (not the OS tree) is vendored. +repository = "Windows OS (internal Azure DevOps)" +# The PSEC schema is a Windows OS containment contract; there is no public OS +# revision to cite. Instead of guessing a source path/revision, we record the +# Windows build the vendored schema contract was validated against. +validated_windows_build = "10.0.26663.1000" +# Azure DevOps PR 16307987 is the RELATED Learning Mode trace ABI change — it is +# NOT the pull request that introduced or last modified this PSEC schema. +# Recorded for traceability only; do not represent it as the schema's origin. +related_trace_abi_pull_request = 16307987 + +[schema] +# SHA-256 of external/windows-sdk/ProcessSecurityEnvironment.fbs, computed over +# the LF-normalized (git-blob) content so it is checkout-independent regardless +# of autocrlf. Verified by regenerate.ps1 and the CI drift gate. +sha256 = "2bb6b5bb5eccf589ffa1d19ad1f011dcc72b3e7eacefa1f0741e123cb259bf29" + +[tool] +# Exact flatc version used to generate the committed bindings. Regeneration +# pins this exact version (not a floor) so output is byte-reproducible. +# 25.12.19 is the first release carrying flatbuffers PR #8709, which stops flatc +# emitting elided lifetimes that trip the `mismatched_lifetime_syntaxes` lint +# (added in Rust 1.89). +flatc_version = "25.12.19" +flatc_release = "https://github.com/google/flatbuffers/releases/tag/v25.12.19" +generated_date = "2026-08-03" + +# Exact GitHub release assets for flatc 25.12.19. The CI drift gate downloads the +# platform asset, verifies its SHA-256 against these values, unzips it, and +# regenerates into a temp directory to diff against the committed bindings. +[tool.flatc_assets.linux] +name = "Linux.flatc.binary.clang++-18.zip" +sha256 = "50c1915deeeb714f2a05c8ec795bd1af898d251a62e2774067703b29188efc90" + +[tool.flatc_assets.windows] +name = "Windows.flatc.binary.zip" +sha256 = "fff9445c9db907227bc64b54cc98743084c4949282aa4e576cff6a955724ddc8" diff --git a/schemas/dev/mxc-config.schema.0.8.0-dev.json b/schemas/dev/mxc-config.schema.0.8.0-dev.json index b62e63198..504446da6 100644 --- a/schemas/dev/mxc-config.schema.0.8.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.8.0-dev.json @@ -46,7 +46,7 @@ }, "CaptureDenials": { "additionalProperties": false, - "description": "Windows denial-capture settings. The presence of the `captureDenials` object enables capture; all fields are optional.", + "description": "Windows denial-capture settings. The presence of the `captureDenials` object enables capture; all fields are optional. Capture is incompatible with `processContainer.leastPrivilege` and `network.proxy`. Explicit `filesystem.deniedPaths` requires the host's V2 process security-environment support query to advertise native deny enforcement.", "properties": { "mode": { "anyOf": [ @@ -699,7 +699,7 @@ "type": "null" } ], - "description": "Windows denial capture. When present, the runner records the sandboxed process's access attempts to a learning-mode ETL trace for later inspection. Requires a host that exposes the learning-mode OS API." + "description": "Windows denial capture. When present, the runner records the sandboxed process's access attempts to a learning-mode ETL trace for later inspection. Requires a host that exposes the complete official V2 Learning Mode and process security-environment API set. Cannot be combined with `leastPrivilege` or `network.proxy`; `filesystem.deniedPaths` additionally requires the V2 deny-support capability." }, "learningMode": { "description": "AppContainer learning mode (deny-and-record): failed access checks are logged for diagnostics while the accesses stay denied; containment is unchanged. Distinct from the allow-all `permissiveLearningMode` capability, which is injected internally by the `--audit` CLI flag or dedicated denial-capture configuration.", diff --git a/scripts/versioning/check-psec-codegen.js b/scripts/versioning/check-psec-codegen.js new file mode 100644 index 000000000..9d2789ec8 --- /dev/null +++ b/scripts/versioning/check-psec-codegen.js @@ -0,0 +1,367 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// PSEC generated-contract drift gate. +// +// The `process_security_environment_spec` crate under src/core/generated/ is +// generated by `flatc` from the vendored schema +// external/windows-sdk/ProcessSecurityEnvironment.fbs. This gate: +// +// 1. verifies the vendored schema SHA-256 matches the recorded provenance +// (external/windows-sdk/ProcessSecurityEnvironment.provenance.toml); +// 2. verifies every committed generated file carries the flatc "do not +// modify." header (a hand-edit guard); +// 3. performs a REAL regenerate-and-diff with the EXACT pinned flatc: it +// downloads the pinned platform release asset from the flatbuffers GitHub +// release, verifies its SHA-256 against the provenance, unzips it, +// regenerates into a temp directory with the same flags, reorganizes + +// patches lib.rs, runs `cargo fmt`, and recursively diffs the result +// against the committed src/; and +// 4. `cargo check`s the committed workspace crate for cross-target compile +// validation. +// +// To reuse a cached flatc (e.g. on a Windows dev box, or an air-gapped runner) +// and skip the download, pass `--flatc ` or set the `FLATC` env var. The +// binary's version must equal the pinned flatc_version. +// +// Run from anywhere (paths resolved relative to repo root): +// node scripts/versioning/check-psec-codegen.js [--flatc ] + +const fs = require("fs"); +const os = require("os"); +const crypto = require("crypto"); +const { join, relative, sep } = require("path"); +const { execFileSync } = require("child_process"); + +const repoRoot = join(__dirname, "..", ".."); +const schemaPath = join(repoRoot, "external", "windows-sdk", "ProcessSecurityEnvironment.fbs"); +const provenancePath = join( + repoRoot, + "external", + "windows-sdk", + "ProcessSecurityEnvironment.provenance.toml" +); +const crateDir = join( + repoRoot, + "src", + "core", + "generated", + "process_security_environment_specification" +); +const committedSrcDir = join(crateDir, "src"); + +function fail(msg) { + console.error("PSEC codegen check FAILED:"); + console.error(" - " + msg); + process.exit(1); +} + +const sha256Hex = (buf) => crypto.createHash("sha256").update(buf).digest("hex"); +const sha256File = (p) => sha256Hex(fs.readFileSync(p)); +// LF-normalize so autocrlf checkouts don't produce false differences. +const lfNormalize = (buf) => + Buffer.from(buf.toString("binary").replace(/\r\n/g, "\n"), "binary"); + +// --- Minimal TOML reader (targeted, no dependency) -------------------------- +function tomlSection(text, header) { + const escaped = header.replace(/[.[\]]/g, "\\$&"); + const m = new RegExp(`^\\[${escaped}\\]\\s*$`, "m").exec(text); + if (!m) return null; + const rest = text.slice(m.index + m[0].length); + const next = rest.search(/^\s*\[/m); + return next === -1 ? rest : rest.slice(0, next); +} +function tomlString(block, key) { + if (block == null) return null; + const m = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]+)"`, "m").exec(block); + return m ? m[1] : null; +} + +function readProvenance() { + let text; + try { + text = fs.readFileSync(provenancePath, "utf8"); + } catch (e) { + fail(`could not read provenance ${provenancePath}: ${e.message}`); + } + const schemaSha = tomlString(tomlSection(text, "schema"), "sha256"); + if (!schemaSha) fail(`provenance missing [schema].sha256: ${provenancePath}`); + const flatcVersion = tomlString(tomlSection(text, "tool"), "flatc_version"); + if (!flatcVersion) fail(`provenance missing [tool].flatc_version: ${provenancePath}`); + const asset = (osName) => { + const b = tomlSection(text, `tool.flatc_assets.${osName}`); + const name = tomlString(b, "name"); + const sha256 = tomlString(b, "sha256"); + return name && sha256 ? { name, sha256: sha256.toLowerCase() } : null; + }; + return { + schemaSha: schemaSha.toLowerCase(), + flatcVersion, + assets: { linux: asset("linux"), windows: asset("windows") }, + }; +} + +// --- flatc acquisition ------------------------------------------------------ +function flatcReportedVersion(flatc) { + const out = execFileSync(flatc, ["--version"], { encoding: "utf8" }); + const m = out.match(/flatc version (\d+\.\d+\.\d+)/); + if (!m) fail(`could not parse flatc version from: ${out.trim()}`); + return m[1]; +} + +function cliFlatc() { + const i = process.argv.indexOf("--flatc"); + if (i !== -1 && process.argv[i + 1]) return process.argv[i + 1]; + if (process.env.FLATC) return process.env.FLATC; + return null; +} + +function acquireFlatc(prov, tmp) { + const provided = cliFlatc(); + if (provided) { + const v = flatcReportedVersion(provided); + if (v !== prov.flatcVersion) { + fail( + `provided flatc version ${v} != pinned ${prov.flatcVersion} ` + + `(--flatc / FLATC=${provided})` + ); + } + return provided; + } + + const platform = process.platform; + const asset = + platform === "win32" + ? prov.assets.windows + : platform === "linux" + ? prov.assets.linux + : null; + if (!asset) { + fail( + `no pinned flatc release asset for platform '${platform}'. ` + + `Set FLATC= to run the drift check here.` + ); + } + + const url = `https://github.com/google/flatbuffers/releases/download/v${prov.flatcVersion}/${asset.name}`; + const zip = join(tmp, asset.name); + console.log(`Downloading pinned flatc asset ${asset.name} ...`); + execFileSync("curl", ["-fsSL", "-o", zip, url], { + stdio: ["ignore", "ignore", "inherit"], + }); + const got = sha256File(zip); + if (got !== asset.sha256) { + fail( + `flatc asset SHA-256 mismatch for ${asset.name}:\n` + + ` expected: ${asset.sha256}\n` + + ` actual: ${got}` + ); + } + + const outDir = join(tmp, "flatc"); + fs.mkdirSync(outDir, { recursive: true }); + if (platform === "win32") { + execFileSync( + "powershell", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Expand-Archive -Path '${zip}' -DestinationPath '${outDir}' -Force`, + ], + { stdio: ["ignore", "ignore", "inherit"] } + ); + } else { + execFileSync("unzip", ["-o", "-q", zip, "-d", outDir], { + stdio: ["ignore", "ignore", "inherit"], + }); + } + + const bin = join(outDir, platform === "win32" ? "flatc.exe" : "flatc"); + if (!fs.existsSync(bin)) fail(`flatc binary not found after extracting ${asset.name}`); + if (platform !== "win32") fs.chmodSync(bin, 0o755); + + const v = flatcReportedVersion(bin); + if (v !== prov.flatcVersion) { + fail(`downloaded flatc version ${v} != pinned ${prov.flatcVersion}`); + } + return bin; +} + +// --- Regeneration (mirrors regenerate.ps1, into a temp dir) ----------------- +const FMT_MANIFEST = `[package] +name = "process_security_environment_spec" +version = "0.7.0" +edition = "2021" +license = "MIT" +publish = false + +[dependencies] +flatbuffers = "25" +`; + +function regenerate(flatc, tmp) { + const genCrate = join(tmp, "gen"); + fs.mkdirSync(genCrate, { recursive: true }); + execFileSync( + flatc, + [ + "--rust", + "--gen-object-api", + "--force-empty", + "--no-prefix", + "--rust-module-root-file", + "--gen-all", + "-o", + genCrate, + schemaPath, + ], + { stdio: ["ignore", "ignore", "inherit"] } + ); + + const genSrc = join(genCrate, "src"); + fs.mkdirSync(genSrc); + fs.renameSync(join(genCrate, "mod.rs"), join(genSrc, "lib.rs")); + fs.renameSync( + join(genCrate, "process_security_environment_layout"), + join(genSrc, "process_security_environment_layout") + ); + + const libRs = join(genSrc, "lib.rs"); + const patched = fs + .readFileSync(libRs, "utf8") + .replace( + "// @generated", + "// @generated\n#![allow(unused_imports, non_snake_case, non_camel_case_types, clippy::all)]" + ); + fs.writeFileSync(libRs, patched); + + fs.writeFileSync(join(genCrate, "Cargo.toml"), FMT_MANIFEST); + execFileSync("cargo", ["fmt", "--manifest-path", join(genCrate, "Cargo.toml")], { + stdio: ["ignore", "ignore", "inherit"], + }); + return genSrc; +} + +function collectRel(dir) { + const out = []; + (function walk(d) { + for (const e of fs.readdirSync(d).sort()) { + const p = join(d, e); + if (fs.statSync(p).isDirectory()) walk(p); + else out.push(relative(dir, p).split(sep).join("/")); + } + })(dir); + return out.sort(); +} + +function diffTrees(committed, generated) { + const a = collectRel(committed); + const b = collectRel(generated); + const setA = new Set(a); + const setB = new Set(b); + const onlyCommitted = a.filter((x) => !setB.has(x)); + const onlyGen = b.filter((x) => !setA.has(x)); + if (onlyCommitted.length || onlyGen.length) { + fail( + "generated file set drifted from committed:\n" + + (onlyCommitted.length + ? " committed-only: " + onlyCommitted.join(", ") + "\n" + : "") + + (onlyGen.length ? " generated-only: " + onlyGen.join(", ") : "") + ); + } + for (const rel of a) { + const c = lfNormalize(fs.readFileSync(join(committed, rel))).toString().split("\n"); + const g = lfNormalize(fs.readFileSync(join(generated, rel))).toString().split("\n"); + if (c.join("\n") !== g.join("\n")) { + let line = 0; + while (line < c.length && line < g.length && c[line] === g[line]) line++; + const show = (arr) => (line < arr.length ? JSON.stringify(arr[line]) : ""); + fail( + `committed generated output is stale at src/${rel}.\n` + + ` First difference at line ${line + 1}:\n` + + ` committed: ${show(c)}\n` + + ` regenerated: ${show(g)}\n` + + ` Regenerate with the crate's regenerate.ps1 (exact flatc ` + + `${provInfo.flatcVersion}).` + ); + } + } + return a.length; +} + +// ============================================================================ +const provInfo = readProvenance(); + +// --- 1. Schema hash matches provenance -------------------------------------- +let schemaBytes; +try { + schemaBytes = fs.readFileSync(schemaPath); +} catch (e) { + fail(`could not read schema ${schemaPath}: ${e.message}`); +} +const actualSchemaSha = sha256Hex(lfNormalize(schemaBytes)); +if (actualSchemaSha !== provInfo.schemaSha) { + fail( + `vendored schema hash drifted from provenance.\n` + + ` schema: ${schemaPath}\n` + + ` expected: ${provInfo.schemaSha} (${provenancePath})\n` + + ` actual: ${actualSchemaSha}\n` + + ` If you intentionally refreshed the schema, update the provenance\n` + + ` (sha256 + source build) and regenerate the bindings (see the crate README).` + ); +} + +// --- 2. Generated files carry the "do not modify" header -------------------- +function isMarkedGenerated(text) { + const head = text.slice(0, 400).toLowerCase(); + return head.includes("@generated") && head.includes("do not modify"); +} +if (!fs.existsSync(committedSrcDir)) { + fail(`generated source directory not found: ${committedSrcDir}`); +} +const committedFiles = collectRel(committedSrcDir); +if (committedFiles.length === 0) { + fail(`no generated files found under ${committedSrcDir}`); +} +const unmarked = committedFiles.filter( + (rel) => !isMarkedGenerated(fs.readFileSync(join(committedSrcDir, rel), "utf8")) +); +if (unmarked.length > 0) { + fail( + "generated file(s) are missing the flatc 'do not modify' header (hand-edited?):\n" + + unmarked.map((f) => " src/" + f).join("\n") + ); +} + +// --- 3. Real regenerate-and-diff with the exact pinned flatc ---------------- +const tmpBase = fs.mkdtempSync(join(os.tmpdir(), "mxc-psec-")); +let comparedCount; +try { + const flatc = acquireFlatc(provInfo, tmpBase); + const genSrc = regenerate(flatc, tmpBase); + comparedCount = diffTrees(committedSrcDir, genSrc); +} finally { + fs.rmSync(tmpBase, { recursive: true, force: true }); +} + +// --- 4. Committed workspace crate compiles (cross-target validation) -------- +try { + execFileSync("cargo", ["check", "-q", "-p", "process_security_environment_spec"], { + cwd: join(repoRoot, "src"), + stdio: ["ignore", "ignore", "inherit"], + }); +} catch (e) { + fail( + `committed crate failed to compile via ` + + `'cargo check -p process_security_environment_spec': ${e.message}` + ); +} + +console.log( + `PSEC codegen OK: schema matches provenance (sha256 ${provInfo.schemaSha.slice(0, 12)}…), ` + + `regenerated with pinned flatc ${provInfo.flatcVersion} and diffed ${comparedCount} files ` + + `(no drift), all carry the do-not-modify header, and the crate compiles.` +); diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 9c5b31a8a..4d18d61ea 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -38,7 +38,7 @@ export interface BaseProcessUi { } /** - * Windows denial-capture settings. The presence of the `captureDenials` object enables capture; all fields are optional. + * Windows denial-capture settings. The presence of the `captureDenials` object enables capture; all fields are optional. Capture is incompatible with `processContainer.leastPrivilege` and `network.proxy`. Explicit `filesystem.deniedPaths` requires the host's V2 process security-environment support query to advertise native deny enforcement. */ export interface CaptureDenials { /** @@ -319,7 +319,7 @@ export interface ProcessContainer { */ capabilities?: string[] | null; /** - * Windows denial capture. When present, the runner records the sandboxed process's access attempts to a learning-mode ETL trace for later inspection. Requires a host that exposes the learning-mode OS API. + * Windows denial capture. When present, the runner records the sandboxed process's access attempts to a learning-mode ETL trace for later inspection. Requires a host that exposes the complete official V2 Learning Mode and process security-environment API set. Cannot be combined with `leastPrivilege` or `network.proxy`; `filesystem.deniedPaths` additionally requires the V2 deny-support capability. */ captureDenials?: CaptureDenials | null; /** diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index bfa958338..eafde029e 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -282,6 +282,11 @@ trait CaptureSessionFactory: Send + Sync { ) -> Result, learning_mode_windows::LearningModeError>; } +trait CapturePlatformSupport: Send + Sync { + fn check_apis(&self) -> Result<(), String>; + fn supports_deny_paths(&self) -> Result; +} + struct RealCaptureSessionFactory; impl CaptureSessionFactory for RealCaptureSessionFactory { @@ -302,11 +307,39 @@ impl CaptureSessionFactory for RealCaptureSessionFactory { } } +struct RealCapturePlatformSupport; + +impl CapturePlatformSupport for RealCapturePlatformSupport { + fn check_apis(&self) -> Result<(), String> { + match (SecurityEnvironmentApi::load(), LearningModeApi::load()) { + (Ok(_), Ok(_)) => Ok(()), + (security_environment_api, learning_mode_api) => { + let detail = match (&security_environment_api, &learning_mode_api) { + (Err(error), _) => format!("security-environment API: {error}"), + (_, Err(error)) => format!("learning-mode trace API: {error}"), + _ => "unknown".to_string(), + }; + Err(detail) + } + } + } + + fn supports_deny_paths(&self) -> Result { + SecurityEnvironmentApi::load() + .map_err(|error| format!("process security-environment API unavailable: {error}"))? + .supports_deny_paths() + .map_err(|error| { + format!("could not query process security-environment support: {error}") + }) + } +} + /// Script runner that uses `Experimental_CreateProcessInSandbox` API /// to launch a sandboxed process. pub struct BaseContainerRunner { proxy_coordinator: ProxyCoordinator, capture_factory: Arc, + capture_support: Arc, } impl Default for BaseContainerRunner { @@ -314,6 +347,7 @@ impl Default for BaseContainerRunner { Self { proxy_coordinator: ProxyCoordinator::default(), capture_factory: Arc::new(RealCaptureSessionFactory), + capture_support: Arc::new(RealCapturePlatformSupport), } } } @@ -348,6 +382,19 @@ impl BaseContainerRunner { Self { proxy_coordinator: ProxyCoordinator::default(), capture_factory, + capture_support: Arc::new(RealCapturePlatformSupport), + } + } + + #[cfg(test)] + fn with_capture_components( + capture_factory: Arc, + capture_support: Arc, + ) -> Self { + Self { + proxy_coordinator: ProxyCoordinator::default(), + capture_factory, + capture_support, } } @@ -625,19 +672,26 @@ impl BaseContainerRunner { builder.finished_data().to_vec() } - fn effective_capabilities(request: &ExecutionRequest) -> Vec { - // Match legacy AppContainer behaviour: when network enforcement uses - // capabilities and the default policy is Allow, ensure internetClient - // is present so the sandboxed process has network access. - let mut caps = request.policy.capabilities.clone(); + fn needs_internet_client(request: &ExecutionRequest) -> bool { let use_caps_for_network = matches!( request.policy.network_enforcement_mode, NetworkEnforcementMode::Capabilities | NetworkEnforcementMode::Both ); - if use_caps_for_network + use_caps_for_network && request.policy.default_network_policy == NetworkPolicy::Allow - && !caps.iter().any(|c| c == "internetClient") - { + && !request + .policy + .capabilities + .iter() + .any(|capability| capability == "internetClient") + } + + fn effective_capabilities(request: &ExecutionRequest) -> Vec { + // Match legacy AppContainer behaviour: when network enforcement uses + // capabilities and the default policy is Allow, ensure internetClient + // is present so the sandboxed process has network access. + let mut caps = request.policy.capabilities.clone(); + if Self::needs_internet_client(request) { caps.push("internetClient".to_string()); } caps @@ -649,11 +703,18 @@ impl BaseContainerRunner { let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024); let version = SchemaVersion::new(1, 0); - let caps = Self::effective_capabilities(request); - let capabilities = if caps.is_empty() { + let needs_internet_client = Self::needs_internet_client(request); + let capabilities = if request.policy.capabilities.is_empty() && !needs_internet_client { None } else { - Some(builder.create_string(&caps.join(","))) + let mut capabilities = request.policy.capabilities.join(","); + if needs_internet_client { + if !capabilities.is_empty() { + capabilities.push(','); + } + capabilities.push_str("internetClient"); + } + Some(builder.create_string(&capabilities)) }; let fs_read_write = create_string_vector(&mut builder, &request.policy.readwrite_paths); @@ -1690,23 +1751,27 @@ impl SandboxBackend for BaseContainerRunner { proxy AppContainer peer identity", )); } + if capture_denials { + self.capture_support + .check_apis() + .map_err(|detail| ScriptResponse { + failure_phase: FailurePhase::BackendUnavailable, + ..ScriptResponse::error(&format!( + "captureDenials requires the official V2 APIs ({detail})" + )) + })?; + } // deniedPaths reaches ordinary BaseContainer through SBOX and capture // through PSEC. Each path has a distinct support query; fail closed // rather than silently dropping the deny policy. if !request.policy.denied_paths.is_empty() { let deny_supported = if capture_denials { - let api = SecurityEnvironmentApi::load().map_err(|error| ScriptResponse { - failure_phase: FailurePhase::BackendUnavailable, - ..ScriptResponse::error(&format!( - "process security-environment API unavailable: {error}" - )) - })?; - api.supports_deny_paths().map_err(|error| ScriptResponse { - failure_phase: FailurePhase::BackendUnavailable, - ..ScriptResponse::error(&format!( - "could not query process security-environment support: {error}" - )) - })? + self.capture_support + .supports_deny_paths() + .map_err(|message| ScriptResponse { + failure_phase: FailurePhase::BackendUnavailable, + ..ScriptResponse::error(&message) + })? } else { crate::fallback_detector::base_container_supports_deny_paths() }; @@ -1727,22 +1792,7 @@ impl SandboxBackend for BaseContainerRunner { )); } if capture_denials { - return match (SecurityEnvironmentApi::load(), LearningModeApi::load()) { - (Ok(_), Ok(_)) => Ok(()), - (security_environment_api, learning_mode_api) => { - let detail = match (&security_environment_api, &learning_mode_api) { - (Err(error), _) => format!("security-environment API: {error}"), - (_, Err(error)) => format!("learning-mode trace API: {error}"), - _ => "unknown".to_string(), - }; - Err(ScriptResponse { - failure_phase: FailurePhase::BackendUnavailable, - ..ScriptResponse::error(&format!( - "captureDenials requires the official V2 APIs ({detail})" - )) - }) - } - }; + return Ok(()); } Self::is_base_container_api_present().map_err(|e| { @@ -2344,6 +2394,44 @@ mod tests { } } + struct FakeCaptureSupport { + api_error: Option<&'static str>, + deny_error: Option<&'static str>, + deny_supported: bool, + api_calls: AtomicUsize, + deny_calls: AtomicUsize, + } + + impl CapturePlatformSupport for FakeCaptureSupport { + fn check_apis(&self) -> Result<(), String> { + self.api_calls.fetch_add(1, Ordering::SeqCst); + self.api_error + .map_or(Ok(()), |error| Err(error.to_string())) + } + + fn supports_deny_paths(&self) -> Result { + self.deny_calls.fetch_add(1, Ordering::SeqCst); + self.deny_error + .map_or(Ok(self.deny_supported), |error| Err(error.to_string())) + } + } + + fn fake_capture_factory() -> Arc { + Arc::new(FakeCaptureFactory { + begin_error: None, + finish_error: None, + begin_calls: AtomicUsize::new(0), + finish_calls: Arc::new(AtomicUsize::new(0)), + }) + } + + fn capture_request_with_denied_path() -> ExecutionRequest { + let mut request = ExecutionRequest::default(); + request.policy.capture_denials = Some(Default::default()); + request.policy.denied_paths = vec![r"C:\secret".to_string()]; + request + } + struct FakeAnalyzer { result: Result, } @@ -3002,6 +3090,77 @@ mod tests { assert!(CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG.contains("cannot fall back to AppContainer")); } + #[test] + fn capture_validation_fails_closed_when_v2_api_is_unavailable() { + let factory = fake_capture_factory(); + let support = Arc::new(FakeCaptureSupport { + api_error: Some("missing CloseLearningModeTrace"), + deny_error: None, + deny_supported: true, + api_calls: AtomicUsize::new(0), + deny_calls: AtomicUsize::new(0), + }); + let runner = BaseContainerRunner::with_capture_components(factory.clone(), support.clone()); + + let error = runner + .validate(&capture_request_with_denied_path()) + .expect_err("missing V2 API must fail closed"); + + assert_eq!(error.failure_phase, FailurePhase::BackendUnavailable); + assert!(error + .error_message + .contains("missing CloseLearningModeTrace")); + assert_eq!(support.api_calls.load(Ordering::SeqCst), 1); + assert_eq!(support.deny_calls.load(Ordering::SeqCst), 0); + assert_eq!(factory.begin_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn capture_validation_fails_closed_when_deny_query_fails() { + let factory = fake_capture_factory(); + let support = Arc::new(FakeCaptureSupport { + api_error: None, + deny_error: Some("query failed"), + deny_supported: false, + api_calls: AtomicUsize::new(0), + deny_calls: AtomicUsize::new(0), + }); + let runner = BaseContainerRunner::with_capture_components(factory.clone(), support.clone()); + + let error = runner + .validate(&capture_request_with_denied_path()) + .expect_err("deny query failure must fail closed"); + + assert_eq!(error.failure_phase, FailurePhase::BackendUnavailable); + assert!(error.error_message.contains("query failed")); + assert_eq!(support.api_calls.load(Ordering::SeqCst), 1); + assert_eq!(support.deny_calls.load(Ordering::SeqCst), 1); + assert_eq!(factory.begin_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn capture_validation_fails_closed_when_deny_bit_is_clear() { + let factory = fake_capture_factory(); + let support = Arc::new(FakeCaptureSupport { + api_error: None, + deny_error: None, + deny_supported: false, + api_calls: AtomicUsize::new(0), + deny_calls: AtomicUsize::new(0), + }); + let runner = BaseContainerRunner::with_capture_components(factory.clone(), support.clone()); + + let error = runner + .validate(&capture_request_with_denied_path()) + .expect_err("missing deny support bit must fail closed"); + + assert_eq!(error.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!(error.error_message, CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG); + assert_eq!(support.api_calls.load(Ordering::SeqCst), 1); + assert_eq!(support.deny_calls.load(Ordering::SeqCst), 1); + assert_eq!(factory.begin_calls.load(Ordering::SeqCst), 0); + } + #[test] fn validate_runner_rejects_capture_denials_with_least_privilege() { let runner = BaseContainerRunner::new(); diff --git a/src/backends/learning_mode/windows/src/ffi.rs b/src/backends/learning_mode/windows/src/ffi.rs index 01c2fb857..706463953 100644 --- a/src/backends/learning_mode/windows/src/ffi.rs +++ b/src/backends/learning_mode/windows/src/ffi.rs @@ -11,6 +11,7 @@ use std::path::Path; use std::ptr; +use std::sync::OnceLock; use std::time::Duration; use windows::Win32::Foundation::{ @@ -120,12 +121,25 @@ impl LearningModeApi { /// Load `processmodel.dll` and resolve the Learning Mode trace exports. /// + /// The result — success or failure — is memoized for the lifetime of the + /// process: `processmodel.dll` is a resident system DLL whose export set does + /// not change while the process runs, so repeated probes would only repeat the + /// same `LoadLibraryExW`/`GetProcAddress` work and return the same answer. The + /// cached error is cloned (see [`LearningModeError`]), preserving the original + /// diagnostic on every call. + /// /// # Errors /// - [`LearningModeError::DllLoad`] if `processmodel.dll` cannot be loaded. /// - [`LearningModeError::ExportMissing`] if any export is absent. Requiring /// `CloseLearningModeTrace` rejects builds that expose the incompatible /// earlier two-export ABI. pub fn load() -> Result { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(Self::load_uncached).clone() + } + + /// Perform the actual DLL load and export resolution, bypassing the cache. + fn load_uncached() -> Result { let dll = string_util::to_wide(PROCESSMODEL_DLL); // SAFETY: `dll` is a valid null-terminated wide string that outlives the call. @@ -138,9 +152,9 @@ impl LearningModeApi { let hmodule = LoadLibraryExW(PCWSTR(dll.as_ptr()), None, LOAD_LIBRARY_SEARCH_SYSTEM32) .map_err(|e| LearningModeError::DllLoad(e.to_string()))?; - let start_proc = resolve_export(hmodule, c"StartLearningModeTrace")?; - let stop_proc = resolve_export(hmodule, c"StopLearningModeTrace")?; - let close_proc = resolve_export(hmodule, c"CloseLearningModeTrace")?; + let start_proc = resolve_export(hmodule, START_NAME)?; + let stop_proc = resolve_export(hmodule, STOP_NAME)?; + let close_proc = resolve_export(hmodule, CLOSE_NAME)?; let start: PfnStartLearningModeTrace = std::mem::transmute(start_proc); let stop: PfnStopLearningModeTrace = std::mem::transmute(stop_proc); @@ -150,6 +164,19 @@ impl LearningModeApi { } } + /// Construct an API surface directly from raw export pointers, bypassing the + /// DLL load. Test-only: lets sibling modules (e.g. `lifecycle`) inject fakes to + /// exercise the capture lifecycle host-independently without going through the + /// memoized [`load`](Self::load) path, so fakes never populate the process cache. + #[cfg(test)] + pub(crate) fn from_raw_parts( + start: PfnStartLearningModeTrace, + stop: PfnStopLearningModeTrace, + close: PfnCloseLearningModeTrace, + ) -> Self { + Self { start, stop, close } + } + /// Start a Learning Mode trace for the sandbox identified by /// `security_environment`. /// @@ -309,11 +336,79 @@ fn last_error() -> u32 { unsafe { GetLastError().0 } } +/// Undecorated names of the three Learning Mode trace exports, in the order the +/// 2-phase capture lifecycle uses them. +const START_NAME: &core::ffi::CStr = c"StartLearningModeTrace"; +const STOP_NAME: &core::ffi::CStr = c"StopLearningModeTrace"; +const CLOSE_NAME: &core::ffi::CStr = c"CloseLearningModeTrace"; + +/// Which Learning Mode trace exports resolved on this machine. +/// +/// This mirrors the security-environment report shape and isolates the pure +/// all-or-nothing completeness rule so it can be unit-tested without a live DLL. +/// Requiring `close` in addition to `start`/`stop` is what rejects the incompatible +/// earlier two-export ("V1") ABI. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct LearningModeExportReport { + /// Resolved name of `StartLearningModeTrace`, if present. + pub start: Option<&'static str>, + /// Resolved name of `StopLearningModeTrace`, if present. + pub stop: Option<&'static str>, + /// Resolved name of `CloseLearningModeTrace`, if present. + pub close: Option<&'static str>, +} + +impl LearningModeExportReport { + /// `true` only when all three trace exports resolved. A start+stop-only build + /// (the legacy two-export ABI) is deliberately incomplete. + pub(crate) fn is_complete(&self) -> bool { + self.start.is_some() && self.stop.is_some() && self.close.is_some() + } +} + +/// Probe `processmodel.dll` for the three Learning Mode trace exports. Returns an +/// all-`None` report if the DLL itself cannot be loaded. +fn probe_learning_mode_exports() -> LearningModeExportReport { + let dll = string_util::to_wide(PROCESSMODEL_DLL); + // SAFETY: `dll` is a valid null-terminated wide string that outlives the call; + // `LOAD_LIBRARY_SEARCH_SYSTEM32` restricts the search to System32. + let hmodule = + match unsafe { LoadLibraryExW(PCWSTR(dll.as_ptr()), None, LOAD_LIBRARY_SEARCH_SYSTEM32) } { + Ok(h) => h, + Err(_) => return LearningModeExportReport::default(), + }; + + // SAFETY: `hmodule` is valid; `export_name_if_present` only reads exports. + unsafe { + LearningModeExportReport { + start: export_name_if_present(hmodule, START_NAME), + stop: export_name_if_present(hmodule, STOP_NAME), + close: export_name_if_present(hmodule, CLOSE_NAME), + } + } +} + +/// Return `name` if it resolves in `hmodule`, otherwise `None`. +/// +/// # Safety +/// `hmodule` must be a valid module handle. +unsafe fn export_name_if_present( + hmodule: HMODULE, + name: &'static core::ffi::CStr, +) -> Option<&'static str> { + // SAFETY: `name` is a valid null-terminated C string; `hmodule` is valid. + if unsafe { GetProcAddress(hmodule, PCSTR(name.as_ptr().cast())) }.is_some() { + name.to_str().ok() + } else { + None + } +} + /// Capability probe: `true` only when `processmodel.dll` exposes all three Learning Mode /// trace exports on this machine. #[must_use] pub fn is_learning_mode_api_available() -> bool { - LearningModeApi::load().is_ok() + probe_learning_mode_exports().is_complete() } #[cfg(test)] @@ -362,11 +457,7 @@ mod tests { } fn fake_api() -> LearningModeApi { - LearningModeApi { - start: fake_start, - stop: fake_stop, - close: fake_close, - } + LearningModeApi::from_raw_parts(fake_start, fake_stop, fake_close) } fn fake_environment() -> HANDLE { @@ -571,4 +662,62 @@ mod tests { assert_eq!(CLOSE_CALLS.load(Ordering::SeqCst), 1); } + + #[test] + fn load_result_is_memoized_and_consistent() { + // `load` memoizes success or failure for the process. Repeated calls must + // agree with each other and with the capability probe, and never panic — + // regardless of whether the API is present on this host. + let first = LearningModeApi::load().is_ok(); + let second = LearningModeApi::load().is_ok(); + assert_eq!(first, second); + assert_eq!(first, is_learning_mode_api_available()); + } + + #[test] + fn learning_mode_report_all_present_is_complete() { + let report = LearningModeExportReport { + start: Some("StartLearningModeTrace"), + stop: Some("StopLearningModeTrace"), + close: Some("CloseLearningModeTrace"), + }; + assert!(report.is_complete()); + } + + #[test] + fn learning_mode_report_each_missing_export_is_incomplete() { + let complete = LearningModeExportReport { + start: Some("StartLearningModeTrace"), + stop: Some("StopLearningModeTrace"), + close: Some("CloseLearningModeTrace"), + }; + + assert!(!LearningModeExportReport { + start: None, + ..complete + } + .is_complete()); + assert!(!LearningModeExportReport { + stop: None, + ..complete + } + .is_complete()); + assert!(!LearningModeExportReport { + close: None, + ..complete + } + .is_complete()); + assert!(!LearningModeExportReport::default().is_complete()); + } + + #[test] + fn learning_mode_report_v1_two_export_subset_is_incomplete() { + // The legacy ABI exposed only Start/Stop. Requiring Close rejects it. + let v1_subset = LearningModeExportReport { + start: Some("StartLearningModeTrace"), + stop: Some("StopLearningModeTrace"), + close: None, + }; + assert!(!v1_subset.is_complete()); + } } diff --git a/src/backends/learning_mode/windows/src/lib.rs b/src/backends/learning_mode/windows/src/lib.rs index da89dba00..bfcb0c4af 100644 --- a/src/backends/learning_mode/windows/src/lib.rs +++ b/src/backends/learning_mode/windows/src/lib.rs @@ -62,7 +62,14 @@ pub use secenv::{ }; /// Errors surfaced while loading or invoking the Learning Mode trace API. -#[derive(Debug, Error)] +/// +/// `Clone` is derived so that [`crate::LearningModeApi::load`] and +/// [`crate::SecurityEnvironmentApi::load`] can memoize a failed load and hand +/// every caller an owned, typed copy of the original diagnostic. Every variant +/// already owns its data (`&'static str`, `String`, or plain integers), so the +/// clone preserves the full message and source information without erasing it +/// behind a stringified surrogate. +#[derive(Debug, Clone, Error)] pub enum LearningModeError { /// `processmodel.dll` itself could not be loaded from System32. #[error("failed to load processmodel.dll: {0}")] diff --git a/src/backends/learning_mode/windows/src/lifecycle.rs b/src/backends/learning_mode/windows/src/lifecycle.rs index c51a48686..b2d50ee5d 100644 --- a/src/backends/learning_mode/windows/src/lifecycle.rs +++ b/src/backends/learning_mode/windows/src/lifecycle.rs @@ -134,3 +134,207 @@ impl Drop for CaptureSession { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::c_void; + use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering}; + use std::sync::{Mutex, MutexGuard}; + use windows::Win32::Foundation::{ERROR_SHARING_VIOLATION, E_FAIL, S_OK}; + use windows_core::HRESULT; + + /// Serializes access to the shared fake-call event log and result knobs. + static TEST_LOCK: Mutex<()> = Mutex::new(()); + /// Ordered log of the fake export calls, as they happen across both APIs. + static EVENTS: Mutex> = Mutex::new(Vec::new()); + + static CREATE_RESULT: AtomicI32 = AtomicI32::new(S_OK.0); + static START_RESULT: AtomicI32 = AtomicI32::new(S_OK.0); + static STOP_RESULT: AtomicI32 = AtomicI32::new(S_OK.0); + static STOP_FAILURE_RESULT: AtomicI32 = AtomicI32::new(E_FAIL.0); + static STOP_FAILURES_REMAINING: AtomicUsize = AtomicUsize::new(0); + + fn record(event: &'static str) { + EVENTS.lock().unwrap().push(event); + } + + fn take_events() -> Vec<&'static str> { + std::mem::take(&mut *EVENTS.lock().unwrap()) + } + + fn dangling_handle() -> HANDLE { + HANDLE(std::ptr::dangling_mut::()) + } + + unsafe extern "system" fn fake_create( + _: *const c_void, + _: u32, + _: u32, + out: *mut HANDLE, + ) -> HRESULT { + record("create"); + let result = HRESULT(CREATE_RESULT.load(Ordering::SeqCst)); + if result.is_ok() { + unsafe { *out = dangling_handle() }; + } + result + } + + unsafe extern "system" fn fake_query(_: *mut u64) -> HRESULT { + S_OK + } + + unsafe extern "system" fn fake_env_close(_: HANDLE) { + record("env_close"); + } + + unsafe extern "system" fn fake_start(_: HANDLE, out: *mut HANDLE) -> HRESULT { + record("start"); + let result = HRESULT(START_RESULT.load(Ordering::SeqCst)); + if result.is_ok() { + unsafe { *out = dangling_handle() }; + } + result + } + + unsafe extern "system" fn fake_stop(_: HANDLE, _: *const u16) -> HRESULT { + record("stop"); + if STOP_FAILURES_REMAINING + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return HRESULT(STOP_FAILURE_RESULT.load(Ordering::SeqCst)); + } + HRESULT(STOP_RESULT.load(Ordering::SeqCst)) + } + + unsafe extern "system" fn fake_trace_close(_: HANDLE) { + record("trace_close"); + } + + fn reset() -> MutexGuard<'static, ()> { + let guard = TEST_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + take_events(); + CREATE_RESULT.store(S_OK.0, Ordering::SeqCst); + START_RESULT.store(S_OK.0, Ordering::SeqCst); + STOP_RESULT.store(S_OK.0, Ordering::SeqCst); + STOP_FAILURE_RESULT.store(E_FAIL.0, Ordering::SeqCst); + STOP_FAILURES_REMAINING.store(0, Ordering::SeqCst); + guard + } + + fn fake_secenv_api() -> SecurityEnvironmentApi { + SecurityEnvironmentApi::from_raw_parts(fake_create, fake_query, fake_env_close) + } + + fn fake_learning_mode_api() -> LearningModeApi { + LearningModeApi::from_raw_parts(fake_start, fake_stop, fake_trace_close) + } + + fn begin_session() -> Result { + CaptureSession::begin( + fake_secenv_api(), + fake_learning_mode_api(), + b"PSEC-fake-spec", + crate::PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE, + ) + } + + #[test] + fn begin_creates_environment_before_starting_trace() { + let _guard = reset(); + let session = begin_session().expect("begin should succeed with passing fakes"); + + // The environment must be created first so the trace keys on a live handle. + assert_eq!(take_events(), vec!["create", "start"]); + assert_eq!(session.environment(), dangling_handle()); + + // Tidy up deterministically so Drop bookkeeping does not leak into siblings. + drop(session); + } + + #[test] + fn begin_start_failure_closes_environment_and_leaves_no_trace() { + let _guard = reset(); + START_RESULT.store(E_FAIL.0, Ordering::SeqCst); + + let error = begin_session().expect_err("start failure must propagate"); + assert!(matches!( + error, + LearningModeError::HResultCall { + function: "StartLearningModeTrace", + code + } if code == E_FAIL.0 + )); + + // The just-created environment is torn down; the trace was never created so + // it is never closed, and Stop is never attempted. + assert_eq!(take_events(), vec!["create", "start", "env_close"]); + } + + #[test] + fn finish_retries_stop_then_closes_trace_then_environment() { + let _guard = reset(); + STOP_FAILURE_RESULT.store( + HRESULT::from_win32(ERROR_SHARING_VIOLATION.0).0, + Ordering::SeqCst, + ); + STOP_FAILURES_REMAINING.store(2, Ordering::SeqCst); + + let session = begin_session().expect("begin should succeed"); + assert_eq!(take_events(), vec!["create", "start"]); + + session + .finish(None) + .expect("finish should succeed after retries"); + + // Stop is retried until it succeeds, THEN the trace closes, THEN the + // environment closes — the exact teardown ordering the OS requires. + assert_eq!( + take_events(), + vec!["stop", "stop", "stop", "trace_close", "env_close"] + ); + } + + #[test] + fn finish_propagates_permanent_stop_failure_but_still_tears_down() { + let _guard = reset(); + STOP_RESULT.store(E_FAIL.0, Ordering::SeqCst); + + let session = begin_session().expect("begin should succeed"); + take_events(); + + let error = session + .finish(None) + .expect_err("a permanent stop failure must surface"); + assert!(matches!( + error, + LearningModeError::HResultCall { + function: "StopLearningModeTrace", + .. + } + )); + + // Even when Stop fails permanently, the trace and environment are still + // closed, in order, so nothing leaks. + assert_eq!(take_events(), vec!["stop", "trace_close", "env_close"]); + } + + #[test] + fn drop_without_finish_discards_trace_then_closes_environment() { + let _guard = reset(); + let session = begin_session().expect("begin should succeed"); + take_events(); + + drop(session); + + // Dropping without `finish` closes (discards) the trace WITHOUT calling + // Stop, then closes the environment. + assert_eq!(take_events(), vec!["trace_close", "env_close"]); + } +} diff --git a/src/backends/learning_mode/windows/src/secenv.rs b/src/backends/learning_mode/windows/src/secenv.rs index 8f65e88eb..623a5d639 100644 --- a/src/backends/learning_mode/windows/src/secenv.rs +++ b/src/backends/learning_mode/windows/src/secenv.rs @@ -33,6 +33,7 @@ use std::ffi::c_void; use std::ptr; +use std::sync::OnceLock; use windows::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER, HANDLE, HMODULE}; use windows::Win32::System::LibraryLoader::{ @@ -308,11 +309,18 @@ const QUERY_SUPPORT_NAMES: &[&core::ffi::CStr] = &[c"QueryProcessSecurityEnviron const CLOSE_NAMES: &[&core::ffi::CStr] = &[c"CloseProcessSecurityEnvironment"]; /// Resolved process security-environment exports from `processmodel.dll`. +/// +/// `cacheable` records whether this surface was produced by the memoizing +/// [`SecurityEnvironmentApi::load`] (the real, process-wide singleton) as opposed +/// to a test fake. Only cacheable surfaces are allowed to populate the process-wide +/// [`supports_deny_paths`](Self::supports_deny_paths) cache, so injected fakes can +/// never poison it for the real API or for each other. #[derive(Clone, Copy)] pub struct SecurityEnvironmentApi { create: PfnCreateProcessSecurityEnvironment, query_support: PfnQueryProcessSecurityEnvironmentSupport, close: PfnCloseProcessSecurityEnvironment, + cacheable: bool, } impl std::fmt::Debug for SecurityEnvironmentApi { @@ -321,6 +329,7 @@ impl std::fmt::Debug for SecurityEnvironmentApi { .field("create", &(self.create as *const ())) .field("query_support", &(self.query_support as *const ())) .field("close", &(self.close as *const ())) + .field("cacheable", &self.cacheable) .finish() } } @@ -328,10 +337,24 @@ impl std::fmt::Debug for SecurityEnvironmentApi { impl SecurityEnvironmentApi { /// Load `processmodel.dll` and resolve the 2-phase security-environment exports. /// + /// The result — success or failure — is memoized for the lifetime of the + /// process: `processmodel.dll` is a resident system DLL whose export set does + /// not change while the process runs, so repeated probes would only repeat the + /// same work and return the same answer. The cached error is cloned (see + /// [`LearningModeError`]), preserving the original diagnostic on every call. The + /// cached surface is marked cacheable so its + /// [`supports_deny_paths`](Self::supports_deny_paths) result is memoized too. + /// /// # Errors /// - [`LearningModeError::DllLoad`] if `processmodel.dll` cannot be loaded. /// - [`LearningModeError::ExportMissing`] if any required export is absent. pub fn load() -> Result { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(Self::load_uncached).clone() + } + + /// Perform the actual DLL load and export resolution, bypassing the cache. + fn load_uncached() -> Result { let dll = string_util::to_wide(PROCESSMODEL_DLL); // SAFETY: `dll` is a valid null-terminated wide string that outlives the call. @@ -360,12 +383,65 @@ impl SecurityEnvironmentApi { unsafe extern "system" fn() -> isize, PfnCloseProcessSecurityEnvironment, >(close_proc), + cacheable: true, }) } } + /// Construct an API surface directly from raw export pointers, bypassing the + /// DLL load. Test-only: lets sibling modules inject fakes. The surface is marked + /// non-cacheable so its [`supports_deny_paths`](Self::supports_deny_paths) result + /// never populates the process-wide cache. + #[cfg(test)] + pub(crate) fn from_raw_parts( + create: PfnCreateProcessSecurityEnvironment, + query_support: PfnQueryProcessSecurityEnvironmentSupport, + close: PfnCloseProcessSecurityEnvironment, + ) -> Self { + Self { + create, + query_support, + close, + cacheable: false, + } + } + + /// Like [`from_raw_parts`](Self::from_raw_parts) but marked cacheable, so the + /// memoization of [`supports_deny_paths`](Self::supports_deny_paths) can be + /// exercised host-independently. Test-only. + #[cfg(test)] + pub(crate) fn from_raw_parts_cacheable( + create: PfnCreateProcessSecurityEnvironment, + query_support: PfnQueryProcessSecurityEnvironmentSupport, + close: PfnCloseProcessSecurityEnvironment, + ) -> Self { + Self { + create, + query_support, + close, + cacheable: true, + } + } + /// Whether the official V2 API supports native deny paths. + /// + /// The answer is a fixed host capability, so for the real (cacheable) API the + /// result — including a typed error — is memoized once per process. Non-cacheable + /// test fakes always query directly and never touch the process-wide cache. pub fn supports_deny_paths(&self) -> Result { + if self.cacheable { + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| self.query_deny_paths_support()) + .clone() + } else { + self.query_deny_paths_support() + } + } + + /// Query `QueryProcessSecurityEnvironmentSupport` for the native-deny-path bit, + /// without consulting or populating the process-wide cache. + fn query_deny_paths_support(&self) -> Result { const PSE_SUPPORT_FS_DENY: u64 = 0x0000_0000_0000_0001; let mut support_flags = 0u64; // SAFETY: `query_support` matches the official V2 declaration and @@ -516,14 +592,53 @@ pub fn is_security_environment_api_available() -> bool { #[cfg(test)] mod tests { use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicI32, AtomicU64, AtomicUsize, Ordering}; + use std::sync::Mutex; + use windows::Win32::Foundation::{E_FAIL, S_OK}; static CLOSE_CALLS: AtomicUsize = AtomicUsize::new(0); + /// Serializes the tests that share the query fakes' global counters. + static QUERY_LOCK: Mutex<()> = Mutex::new(()); + static QUERY_CALLS: AtomicUsize = AtomicUsize::new(0); + static QUERY_RESULT: AtomicI32 = AtomicI32::new(S_OK.0); + static QUERY_FLAGS: AtomicU64 = AtomicU64::new(0); + + /// Native-deny-path support bit reported by `QueryProcessSecurityEnvironmentSupport`. + const PSE_SUPPORT_FS_DENY: u64 = 0x0000_0000_0000_0001; + unsafe extern "system" fn fake_close(_: HANDLE) { CLOSE_CALLS.fetch_add(1, Ordering::SeqCst); } + unsafe extern "system" fn fake_create( + _: *const c_void, + _: u32, + _: u32, + _: *mut HANDLE, + ) -> HRESULT { + S_OK + } + + unsafe extern "system" fn fake_query(support_flags: *mut u64) -> HRESULT { + QUERY_CALLS.fetch_add(1, Ordering::SeqCst); + let result = HRESULT(QUERY_RESULT.load(Ordering::SeqCst)); + if result.is_ok() { + unsafe { *support_flags = QUERY_FLAGS.load(Ordering::SeqCst) }; + } + result + } + + fn reset_query_fakes() { + QUERY_CALLS.store(0, Ordering::SeqCst); + QUERY_RESULT.store(S_OK.0, Ordering::SeqCst); + QUERY_FLAGS.store(0, Ordering::SeqCst); + } + + fn fake_uncached_api() -> SecurityEnvironmentApi { + SecurityEnvironmentApi::from_raw_parts(fake_create, fake_query, fake_close) + } + #[test] fn probe_does_not_panic_and_agrees_with_load() { let report = probe_security_environment_exports(); @@ -593,4 +708,107 @@ mod tests { environment.close(); assert_eq!(CLOSE_CALLS.load(Ordering::SeqCst), 1); } + + #[test] + fn export_report_all_present_is_complete() { + let report = SecurityEnvironmentExportReport { + create: Some("CreateProcessSecurityEnvironment"), + query_support: Some("QueryProcessSecurityEnvironmentSupport"), + close: Some("CloseProcessSecurityEnvironment"), + }; + assert!(report.is_complete()); + } + + #[test] + fn export_report_each_missing_export_is_incomplete() { + let complete = SecurityEnvironmentExportReport { + create: Some("CreateProcessSecurityEnvironment"), + query_support: Some("QueryProcessSecurityEnvironmentSupport"), + close: Some("CloseProcessSecurityEnvironment"), + }; + + assert!(!SecurityEnvironmentExportReport { + create: None, + ..complete + } + .is_complete()); + assert!(!SecurityEnvironmentExportReport { + query_support: None, + ..complete + } + .is_complete()); + assert!(!SecurityEnvironmentExportReport { + close: None, + ..complete + } + .is_complete()); + assert!(!SecurityEnvironmentExportReport::default().is_complete()); + } + + #[test] + fn supports_deny_paths_reports_flag_state() { + let _guard = QUERY_LOCK.lock().unwrap(); + let api = fake_uncached_api(); + + reset_query_fakes(); + QUERY_FLAGS.store(PSE_SUPPORT_FS_DENY, Ordering::SeqCst); + assert!(api.supports_deny_paths().unwrap()); + assert_eq!(QUERY_CALLS.load(Ordering::SeqCst), 1); + + reset_query_fakes(); + QUERY_FLAGS.store(0, Ordering::SeqCst); + assert!(!api.supports_deny_paths().unwrap()); + + reset_query_fakes(); + // Unrelated support bits must not be mistaken for deny-path support. + QUERY_FLAGS.store(0xFFFF_FFFF_FFFF_FFFE, Ordering::SeqCst); + assert!(!api.supports_deny_paths().unwrap()); + } + + #[test] + fn supports_deny_paths_maps_failing_hresult() { + let _guard = QUERY_LOCK.lock().unwrap(); + reset_query_fakes(); + QUERY_RESULT.store(E_FAIL.0, Ordering::SeqCst); + let api = fake_uncached_api(); + + let error = api.supports_deny_paths().unwrap_err(); + assert!(matches!( + error, + LearningModeError::HResultCall { + function: "QueryProcessSecurityEnvironmentSupport", + code + } if code == E_FAIL.0 + )); + } + + #[test] + fn non_cacheable_api_queries_every_call() { + let _guard = QUERY_LOCK.lock().unwrap(); + reset_query_fakes(); + QUERY_FLAGS.store(PSE_SUPPORT_FS_DENY, Ordering::SeqCst); + let api = fake_uncached_api(); + + assert!(api.supports_deny_paths().unwrap()); + assert!(api.supports_deny_paths().unwrap()); + // A test fake must never be memoized: both calls hit the underlying query. + assert_eq!(QUERY_CALLS.load(Ordering::SeqCst), 2); + } + + #[test] + fn cacheable_api_memoizes_support_query() { + // The only test that drives the cacheable (process-wide) support cache, so + // the `OnceLock` initializer runs deterministically here. + let _guard = QUERY_LOCK.lock().unwrap(); + reset_query_fakes(); + QUERY_FLAGS.store(PSE_SUPPORT_FS_DENY, Ordering::SeqCst); + let api = + SecurityEnvironmentApi::from_raw_parts_cacheable(fake_create, fake_query, fake_close); + + let first = api.supports_deny_paths().unwrap(); + let second = api.supports_deny_paths().unwrap(); + assert_eq!(first, second); + // The result is memoized for the process: the query runs at most once. + assert_eq!(QUERY_CALLS.load(Ordering::SeqCst), 1); + } } diff --git a/src/core/generated/process_security_environment_specification/Cargo.toml b/src/core/generated/process_security_environment_specification/Cargo.toml index 04f9115b3..394accdd8 100644 --- a/src/core/generated/process_security_environment_specification/Cargo.toml +++ b/src/core/generated/process_security_environment_specification/Cargo.toml @@ -1,3 +1,7 @@ +# Generated crate: bindings under src/ are produced by `flatc` from +# external/windows-sdk/ProcessSecurityEnvironment.fbs. Do not hand-edit them — +# see README.md and regenerate.ps1. Provenance (schema hash, pinned flatc +# version, source build) lives in the schema's .provenance.toml. [package] name = "process_security_environment_spec" version.workspace = true diff --git a/src/core/generated/process_security_environment_specification/README.md b/src/core/generated/process_security_environment_specification/README.md index d8db7231f..8322e9679 100644 --- a/src/core/generated/process_security_environment_specification/README.md +++ b/src/core/generated/process_security_environment_specification/README.md @@ -1,12 +1,46 @@ -# Regenerating Process Security Environment bindings +# `process_security_environment_spec` -This crate contains Rust bindings generated from -`external/windows-sdk/ProcessSecurityEnvironment.fbs`. +Rust bindings **generated** from `external/windows-sdk/ProcessSecurityEnvironment.fbs` +by the FlatBuffers compiler (`flatc`). -Install `flatc` 25.12.19 or newer, then run from the repository root: +> **Do not hand-edit the files under `src/`.** They are generated; every file +> carries a `// Automatically generated by the Flatbuffers compiler. Do not +> modify.` header. Change the `.fbs` schema (with provenance) and rerun the +> regeneration script instead. The CI drift gate +> (`scripts/versioning/check-psec-codegen.js`) fails if the committed schema or +> generated crate drifts. + +## Provenance + +The vendored schema originates from the internal Microsoft Windows OS repository +and is not publicly redistributable. Authoritative provenance — the source +pull request, the schema SHA-256, and the exact `flatc` version used — is +recorded in +[`external/windows-sdk/ProcessSecurityEnvironment.provenance.toml`](../../../../external/windows-sdk/ProcessSecurityEnvironment.provenance.toml). +That file is the single source of truth consumed by both the regeneration +script and the CI drift gate. + +## Regenerating + +Install the **exact** `flatc` version pinned in the provenance file +(currently `25.12.19`), then run from the repository root: ```powershell pwsh -File src/core/generated/process_security_environment_specification/regenerate.ps1 ``` -Pass `-Flatc ` when `flatc.exe` is not on `PATH`. +Pass `-Flatc ` when `flatc.exe` is not on `PATH`. The script validates the +schema SHA-256 against the provenance file and refuses any `flatc` version other +than the pinned one, so output is byte-reproducible. Older `flatc` releases emit +elided lifetimes that trip the `mismatched_lifetime_syntaxes` lint (Rust 1.89+); +`25.12.19` is the first release with the upstream fix (flatbuffers PR #8709). + +## Workspace membership + +The schema describes a **Windows-only** OS contract, and the crate is consumed +only under `[target.'cfg(target_os = "windows")'.dependencies]` by the +AppContainer/Learning-Mode backends. It remains a workspace member for now, +matching the existing generated `sandbox_spec` crate. The generated bindings +are pure Rust, so cross-platform workspace builds remain valid. Avoiding this +minor non-Windows build cost can be considered separately from the V2 contract +correctness work. diff --git a/src/core/generated/process_security_environment_specification/regenerate.ps1 b/src/core/generated/process_security_environment_specification/regenerate.ps1 index 1f1d808c8..c36917bca 100644 --- a/src/core/generated/process_security_environment_specification/regenerate.ps1 +++ b/src/core/generated/process_security_environment_specification/regenerate.ps1 @@ -1,3 +1,26 @@ +<# +.SYNOPSIS + Regenerates the FlatBuffers Rust bindings for the + process_security_environment_spec crate, reproducibly. + +.DESCRIPTION + Runs `flatc` against external/windows-sdk/ProcessSecurityEnvironment.fbs and + rewrites the output into the crate's module layout. Before generating it: + * validates the vendored schema's SHA-256 against the recorded provenance + (external/windows-sdk/ProcessSecurityEnvironment.provenance.toml), and + * pins the EXACT flatc version recorded in that provenance file, so the + generated output is byte-reproducible. + + The generated files are checked in and must NOT be hand-edited; rerun this + script instead. The CI drift gate (scripts/versioning/check-psec-codegen.js) + fails if the committed schema or generated crate drifts. + +.PARAMETER Flatc + Path to flatc.exe. Defaults to "flatc.exe" (must be on PATH). + +.EXAMPLE + pwsh -File src/core/generated/process_security_environment_specification/regenerate.ps1 +#> [CmdletBinding()] param( [string]$Flatc = "flatc.exe" @@ -14,28 +37,60 @@ Set-Location $repoRoot $crateDir = $PSScriptRoot $srcDir = Join-Path $crateDir "src" $fbs = "external\windows-sdk\ProcessSecurityEnvironment.fbs" +$provenanceFile = "external\windows-sdk\ProcessSecurityEnvironment.provenance.toml" if (-not (Test-Path $fbs)) { throw "FlatBuffers schema not found: $fbs" } +if (-not (Test-Path $provenanceFile)) { + throw "Provenance file not found: $provenanceFile" +} if (-not (Test-Path $Flatc) -and -not (Get-Command $Flatc -ErrorAction SilentlyContinue)) { - throw "flatc not found: $Flatc" + throw "flatc not found: $Flatc. Download from https://github.com/google/flatbuffers/releases" } -$minFlatcVersion = [version]'25.12.19' +# --- Read pinned toolchain + expected schema hash from provenance ------------ +$provenance = Get-Content $provenanceFile -Raw +$pinnedFlatc = [regex]::Match($provenance, 'flatc_version\s*=\s*"([^"]+)"') +$expectedHash = [regex]::Match($provenance, 'sha256\s*=\s*"([^"]+)"') +if (-not $pinnedFlatc.Success) { + throw "Could not read flatc_version from $provenanceFile" +} +if (-not $expectedHash.Success) { + throw "Could not read schema sha256 from $provenanceFile" +} +$pinnedFlatcVersion = $pinnedFlatc.Groups[1].Value +$expectedSchemaHash = $expectedHash.Groups[1].Value.ToLower() + +# --- Validate the vendored schema hash matches provenance -------------------- +# Hash the LF-normalized content so the check is checkout-independent (autocrlf). +$schemaText = (Get-Content $fbs -Raw) -replace "`r`n", "`n" +$schemaBytes = [System.Text.Encoding]::UTF8.GetBytes($schemaText) +$sha = [System.Security.Cryptography.SHA256]::Create() +$actualSchemaHash = (($sha.ComputeHash($schemaBytes) | ForEach-Object { $_.ToString("x2") }) -join "") +if ($actualSchemaHash -ne $expectedSchemaHash) { + throw "Schema hash mismatch for $fbs.`n expected (provenance): $expectedSchemaHash`n actual (on disk): $actualSchemaHash`nIf you intentionally refreshed the schema, update $provenanceFile (sha256 + source revision) first." +} +Write-Host "Schema hash OK ($expectedSchemaHash)" -ForegroundColor Cyan + +# --- Pin the EXACT flatc version for reproducible output --------------------- $versionOutput = (& $Flatc --version) 2>&1 | Out-String $match = [regex]::Match($versionOutput, 'flatc version (\d+\.\d+\.\d+)') if (-not $match.Success) { - throw "Could not parse flatc version: $versionOutput" + throw "Could not parse flatc version from output: $versionOutput" } -if ([version]$match.Groups[1].Value -lt $minFlatcVersion) { - throw "flatc must be at least $minFlatcVersion" +$flatcVersion = $match.Groups[1].Value +if ($flatcVersion -ne $pinnedFlatcVersion) { + throw "flatc version $flatcVersion does not match the pinned version $pinnedFlatcVersion (from $provenanceFile). Install the exact version for reproducible output: https://github.com/google/flatbuffers/releases/tag/v$pinnedFlatcVersion" } +Write-Host "Using pinned flatc version $flatcVersion" -ForegroundColor Cyan +Write-Host "Cleaning previous generated output..." -ForegroundColor Cyan if (Test-Path $srcDir) { Remove-Item $srcDir -Recurse -Force } +Write-Host "Running flatc..." -ForegroundColor Cyan & $Flatc ` --rust --gen-object-api --force-empty --no-prefix --rust-module-root-file --gen-all ` -o $crateDir ` @@ -44,16 +99,19 @@ if ($LASTEXITCODE -ne 0) { throw "flatc failed with exit code $LASTEXITCODE" } +Write-Host "Reorganizing generated files..." -ForegroundColor Cyan New-Item -ItemType Directory -Path $srcDir | Out-Null Move-Item (Join-Path $crateDir "mod.rs") (Join-Path $srcDir "lib.rs") Move-Item (Join-Path $crateDir "process_security_environment_layout") ` (Join-Path $srcDir "process_security_environment_layout") +Write-Host "Patching lib.rs (lint suppression)..." -ForegroundColor Cyan $libRs = Join-Path $srcDir "lib.rs" (Get-Content $libRs) ` -replace '// @generated', "// @generated`n#![allow(unused_imports, non_snake_case, non_camel_case_types, clippy::all)]" | Set-Content $libRs +Write-Host "Formatting with cargo fmt..." -ForegroundColor Cyan Push-Location src try { & cargo fmt -p process_security_environment_spec @@ -63,3 +121,5 @@ try { } finally { Pop-Location } + +Write-Host "Done. Regenerated bindings in $srcDir" -ForegroundColor Green diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index a11ab20d9..19e9028c9 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -208,14 +208,20 @@ pub struct ProcessContainer { pub capabilities: Option>, /// Windows denial capture. When present, the runner records the sandboxed /// process's access attempts to a learning-mode ETL trace for later - /// inspection. Requires a host that exposes the learning-mode OS API. + /// inspection. Requires a host that exposes the complete official V2 + /// Learning Mode and process security-environment API set. Cannot be + /// combined with `leastPrivilege` or `network.proxy`; `filesystem.deniedPaths` + /// additionally requires the V2 deny-support capability. pub capture_denials: Option, /// BaseProcessContainer UI settings (Windows). pub ui: Option, } /// Windows denial-capture settings. The presence of the `captureDenials` -/// object enables capture; all fields are optional. +/// object enables capture; all fields are optional. Capture is incompatible +/// with `processContainer.leastPrivilege` and `network.proxy`. Explicit +/// `filesystem.deniedPaths` requires the host's V2 process security-environment +/// support query to advertise native deny enforcement. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] From c9934c8acd5f884025bc67c2f327ce4351b3eca8 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 3 Aug 2026 19:54:02 -0700 Subject: [PATCH 08/14] Avoid dynamic regex in PSEC provenance parser Parse the small TOML provenance file line-by-line to satisfy CodeQL and avoid incomplete escaping risks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a8860a9-bb20-48ef-91a1-3da8e34b92fb --- scripts/versioning/check-psec-codegen.js | 25 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/scripts/versioning/check-psec-codegen.js b/scripts/versioning/check-psec-codegen.js index 9d2789ec8..dc04e46d5 100644 --- a/scripts/versioning/check-psec-codegen.js +++ b/scripts/versioning/check-psec-codegen.js @@ -65,17 +65,26 @@ const lfNormalize = (buf) => // --- Minimal TOML reader (targeted, no dependency) -------------------------- function tomlSection(text, header) { - const escaped = header.replace(/[.[\]]/g, "\\$&"); - const m = new RegExp(`^\\[${escaped}\\]\\s*$`, "m").exec(text); - if (!m) return null; - const rest = text.slice(m.index + m[0].length); - const next = rest.search(/^\s*\[/m); - return next === -1 ? rest : rest.slice(0, next); + const lines = text.split(/\r?\n/); + const marker = `[${header}]`; + const start = lines.findIndex((line) => line.trim() === marker); + if (start === -1) return null; + let end = start + 1; + while (end < lines.length && !lines[end].trimStart().startsWith("[")) { + end++; + } + return lines.slice(start + 1, end).join("\n"); } function tomlString(block, key) { if (block == null) return null; - const m = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]+)"`, "m").exec(block); - return m ? m[1] : null; + for (const line of block.split(/\r?\n/)) { + const separator = line.indexOf("="); + if (separator === -1 || line.slice(0, separator).trim() !== key) continue; + const value = line.slice(separator + 1).trim(); + const match = /^"([^"]+)"(?:\s+#.*)?$/.exec(value); + return match ? match[1] : null; + } + return null; } function readProvenance() { From 580c19ef89fc925fa308aa827cca01c94e8c13c4 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 5 Aug 2026 10:37:04 -0700 Subject: [PATCH 09/14] Route BaseContainer APIs by schema version Use the legacy SBOX contract through schema 0.7 and the PSEC contract for schema 0.8 and later, retaining PSEC ownership through child teardown. Share network policy semantics across both serializers and document FlatBuffers evolution rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- .../ProcessSecurityEnvironment.fbs | 33 +- ...ProcessSecurityEnvironment.provenance.toml | 2 +- src/Cargo.lock | 1 + src/backends/appcontainer/common/Cargo.toml | 1 + .../common/src/base_container_runner.rs | 613 ++++++++++++------ 5 files changed, 441 insertions(+), 209 deletions(-) diff --git a/external/windows-sdk/ProcessSecurityEnvironment.fbs b/external/windows-sdk/ProcessSecurityEnvironment.fbs index b094d6ab6..a4403a8d6 100644 --- a/external/windows-sdk/ProcessSecurityEnvironment.fbs +++ b/external/windows-sdk/ProcessSecurityEnvironment.fbs @@ -1,4 +1,35 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// FlatBuffers schema for the BaseContainer sandbox specification used in the +// CreateProcessSecurityEnvironment Windows API. +// +// This is the CPSE wire format. The legacy CPIS path continues to use +// SandboxSpec.fbs and its "SBOX" file identifier. +// +// ---- FlatBuffers Schema Evolution Rules ---- +// +// Compatible (non-breaking) changes: +// - Add new fields to the END of a table (they get the next vtable slot). +// - Add new tables, structs, enums, or union members. +// - Deprecate a field with (deprecated) - the slot is preserved, readers skip it. +// - Rename a field or table (wire format uses slot indices, not names). +// +// BREAKING changes (will corrupt existing buffers): +// - Remove or reorder fields in a table or struct. +// - Change a field's type (e.g. uint32 -> int32, or scalar -> string). +// - Change a field's default value. +// - Add/remove/reorder values in an enum that is already serialized. +// - Change the file_identifier or root_type. +// - Change a field from scalar to non-scalar (or vice versa). +// +// To machine-check for breaking changes, keep a copy of the last shipped schema +// (e.g. ProcessSecurityEnvironment.previous.fbs) and run: +// +// flatc --conform ProcessSecurityEnvironment.previous.fbs ProcessSecurityEnvironment.fbs +// +// --conform verifies that every field/enum/table in the old schema still exists +// at the same vtable slot, type, and default in the new schema. namespace ProcessSecurityEnvironmentLayout; diff --git a/external/windows-sdk/ProcessSecurityEnvironment.provenance.toml b/external/windows-sdk/ProcessSecurityEnvironment.provenance.toml index c225dde47..530706ac9 100644 --- a/external/windows-sdk/ProcessSecurityEnvironment.provenance.toml +++ b/external/windows-sdk/ProcessSecurityEnvironment.provenance.toml @@ -28,7 +28,7 @@ related_trace_abi_pull_request = 16307987 # SHA-256 of external/windows-sdk/ProcessSecurityEnvironment.fbs, computed over # the LF-normalized (git-blob) content so it is checkout-independent regardless # of autocrlf. Verified by regenerate.ps1 and the CI drift gate. -sha256 = "2bb6b5bb5eccf589ffa1d19ad1f011dcc72b3e7eacefa1f0741e123cb259bf29" +sha256 = "7d14b01850a735329da00cde4d4d2e32e463f49026a39708be9059fd64e764d3" [tool] # Exact flatc version used to generate the committed bindings. Regeneration diff --git a/src/Cargo.lock b/src/Cargo.lock index c827cfcac..bd80afa2c 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -92,6 +92,7 @@ dependencies = [ "learning_mode_windows", "process_security_environment_spec", "sandbox_spec", + "semver", "serde", "serde_json", "tempfile", diff --git a/src/backends/appcontainer/common/Cargo.toml b/src/backends/appcontainer/common/Cargo.toml index 835e36794..87a635559 100644 --- a/src/backends/appcontainer/common/Cargo.toml +++ b/src/backends/appcontainer/common/Cargo.toml @@ -18,6 +18,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } getrandom = { workspace = true } +semver = "1" [target.'cfg(target_os = "windows")'.dependencies] windows = { workspace = true } diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index 6641c5f69..ce62baed2 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -1,13 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! `BaseContainerRunner` — executes scripts via `Experimental_CreateProcessInSandbox` API. +//! `BaseContainerRunner` — executes scripts through the Windows BaseContainer APIs. //! -//! When `wxc-exec` receives a config with `schema_version` >= 0.5, this runner: -//! 1. Builds a FlatBuffer `SandboxSpec` from the container policy -//! 2. Loads `processmodel.dll` dynamically -//! 3. Calls `Experimental_CreateProcessInSandbox` to launch the child process -//! 4. Waits for the process to exit and returns the result +//! Schema versions through 0.7 use the legacy `SandboxSpec` / one-shot +//! `Experimental_CreateProcessInSandbox` path. Schema 0.8 and later use the +//! PSEC 1.0 / `CreateProcessSecurityEnvironment` two-phase contract and attach +//! the resulting environment to `CreateProcessW`. use std::ffi::c_void; use std::fmt::Write; @@ -20,9 +19,10 @@ use learning_mode_core::{ write_document, DenialAnalyzer, DenialSummary, DenialsDocument, DenialsOutputPointer, }; use learning_mode_windows::{ - CaptureSession, EtlDenialAnalyzer, LearningModeApi, SecurityEnvironmentApi, - SecurityEnvironmentStartupInfo, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE, + CaptureSession, EtlDenialAnalyzer, LearningModeApi, ProcessSecurityEnvironment, + SecurityEnvironmentApi, SecurityEnvironmentStartupInfo, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE, }; +use semver::Version; use windows::Win32::Foundation::{ CloseHandle, GetLastError, SetHandleInformation, ERROR_CALL_NOT_IMPLEMENTED, E_NOTIMPL, HANDLE, @@ -49,14 +49,17 @@ use crate::launch_diagnostics::{ use crate::proxy_coordinator::ProxyCoordinator; use crate::sandbox_tracking::{self, TrackingEntry}; use process_security_environment_spec::process_security_environment_layout::{ - finish_process_security_environment_buffer, EndpointPolicy, EndpointPolicyArgs, FilterAction, + finish_process_security_environment_buffer, EndpointPolicy as PsecEndpointPolicy, + EndpointPolicyArgs as PsecEndpointPolicyArgs, FilterAction as PsecFilterAction, NetworkPolicy as PsecNetworkPolicy, NetworkPolicyArgs as PsecNetworkPolicyArgs, - ProcessSecurityEnvironment, ProcessSecurityEnvironmentArgs, SchemaVersion, + ProcessSecurityEnvironment as PsecProcessSecurityEnvironment, + ProcessSecurityEnvironmentArgs as PsecProcessSecurityEnvironmentArgs, + ProxyInfo as PsecProxyInfo, ProxyInfoArgs as PsecProxyInfoArgs, SchemaVersion, }; use sandbox_spec::base_container_layout::{ endpoint_policy, endpoint_policyArgs, finish_sandbox_spec_buffer, proxy_info, proxy_infoArgs, - FilterAction, IntegrityLevel, NetworkPolicy as FbsNetworkPolicy, NetworkPolicyArgs, - SandboxSpec, SandboxSpecArgs, + FilterAction as SboxFilterAction, IntegrityLevel, NetworkPolicy as FbsNetworkPolicy, + NetworkPolicyArgs, SandboxSpec, SandboxSpecArgs, }; use wxc_common::log_symbols::{ EMOJI_ALLOWED, EMOJI_BLOCKED, EMOJI_NEUTRAL, EMOJI_SECTION, EMOJI_WARNING, @@ -227,11 +230,11 @@ const SANDBOX_CAP_CREATE_PROCESS_IN_SANDBOX: u64 = 0x0000_0000_0000_0001; const SANDBOX_CAP_FS_DENY: u64 = 0x0000_0000_0000_0002; const CAPTURE_API_AVAILABLE_LOG: &str = "captureDenials: learning-mode trace API available (processmodel.dll)"; -const CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG: &str = - "processContainer.captureDenials with filesystem.deniedPaths requires \ +const PSEC_DENIED_PATHS_UNSUPPORTED_MSG: &str = + "schema version 0.8.0 and later with filesystem.deniedPaths requires \ QueryProcessSecurityEnvironmentSupport to advertise PSE_SUPPORT_FS_DENY; \ - this OS build does not support that policy, and capture cannot fall back \ - to AppContainer or host-DACL enforcement"; + this OS build does not support that policy, and the process-security-environment \ + path cannot fall back to AppContainer or host-DACL enforcement"; const CREATE_PROCESS_IN_SANDBOX_API: &str = "Experimental_CreateProcessInSandbox"; const CREATE_PROCESS_IN_SECURITY_ENVIRONMENT_API: &str = "CreateProcessW(PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT)"; @@ -284,7 +287,7 @@ trait CaptureSessionFactory: Send + Sync { } trait CapturePlatformSupport: Send + Sync { - fn check_apis(&self) -> Result<(), String>; + fn check_apis(&self, require_learning_mode: bool) -> Result<(), String>; fn supports_deny_paths(&self) -> Result; } @@ -311,18 +314,13 @@ impl CaptureSessionFactory for RealCaptureSessionFactory { struct RealCapturePlatformSupport; impl CapturePlatformSupport for RealCapturePlatformSupport { - fn check_apis(&self) -> Result<(), String> { - match (SecurityEnvironmentApi::load(), LearningModeApi::load()) { - (Ok(_), Ok(_)) => Ok(()), - (security_environment_api, learning_mode_api) => { - let detail = match (&security_environment_api, &learning_mode_api) { - (Err(error), _) => format!("security-environment API: {error}"), - (_, Err(error)) => format!("learning-mode trace API: {error}"), - _ => "unknown".to_string(), - }; - Err(detail) - } + fn check_apis(&self, require_learning_mode: bool) -> Result<(), String> { + SecurityEnvironmentApi::load() + .map_err(|error| format!("security-environment API: {error}"))?; + if require_learning_mode { + LearningModeApi::load().map_err(|error| format!("learning-mode trace API: {error}"))?; } + Ok(()) } fn supports_deny_paths(&self) -> Result { @@ -335,6 +333,11 @@ impl CapturePlatformSupport for RealCapturePlatformSupport { } } +enum ResolvedNetworkPolicy<'a> { + Proxy(Option<&'a ProxyAddress>), + Egress(&'a NetworkPolicy), +} + /// Script runner that uses `Experimental_CreateProcessInSandbox` API /// to launch a sandboxed process. pub struct BaseContainerRunner { @@ -555,45 +558,156 @@ impl BaseContainerRunner { !is_api_not_implemented(err.0) } + fn resolved_network_policy(policy: &ContainerPolicy) -> ResolvedNetworkPolicy<'_> { + if policy.network_proxy.is_enabled() { + ResolvedNetworkPolicy::Proxy(policy.network_proxy.address.as_ref()) + } else { + ResolvedNetworkPolicy::Egress(&policy.default_network_policy) + } + } + // A BaseContainer network policy contains either proxy settings or an egress policy. fn build_network_policy<'a>( builder: &mut flatbuffers::FlatBufferBuilder<'a>, policy: &ContainerPolicy, ) -> flatbuffers::WIPOffset> { - if policy.network_proxy.is_enabled() { - let proxy = policy.network_proxy.address.as_ref().map(|address| { - let url = builder.create_string(&address.to_url()); - proxy_info::create(builder, &proxy_infoArgs { url: Some(url) }) - }); + match Self::resolved_network_policy(policy) { + ResolvedNetworkPolicy::Proxy(address) => { + let proxy = address.map(|address| { + let url = builder.create_string(&address.to_url()); + proxy_info::create(builder, &proxy_infoArgs { url: Some(url) }) + }); - return FbsNetworkPolicy::create( - builder, - &NetworkPolicyArgs { - proxy, - ..Default::default() - }, - ); + FbsNetworkPolicy::create( + builder, + &NetworkPolicyArgs { + proxy, + ..Default::default() + }, + ) + } + ResolvedNetworkPolicy::Egress(default_policy) => { + let default_action = match default_policy { + NetworkPolicy::Allow => SboxFilterAction::allow, + NetworkPolicy::Block => SboxFilterAction::deny, + }; + let egress = endpoint_policy::create( + builder, + &endpoint_policyArgs { + default_action, + ..Default::default() + }, + ); + + FbsNetworkPolicy::create( + builder, + &NetworkPolicyArgs { + egress: Some(egress), + ..Default::default() + }, + ) + } + } + } + + fn build_process_security_environment_network_policy<'a>( + builder: &mut flatbuffers::FlatBufferBuilder<'a>, + policy: &ContainerPolicy, + ) -> flatbuffers::WIPOffset> { + match Self::resolved_network_policy(policy) { + ResolvedNetworkPolicy::Proxy(address) => { + let proxy = address.map(|address| { + let url = builder.create_string(&address.to_url()); + PsecProxyInfo::create(builder, &PsecProxyInfoArgs { url: Some(url) }) + }); + + PsecNetworkPolicy::create( + builder, + &PsecNetworkPolicyArgs { + proxy, + ..Default::default() + }, + ) + } + ResolvedNetworkPolicy::Egress(default_policy) => { + let default_action = match default_policy { + NetworkPolicy::Allow => PsecFilterAction::allow, + NetworkPolicy::Block => PsecFilterAction::deny, + }; + let egress = PsecEndpointPolicy::create( + builder, + &PsecEndpointPolicyArgs { + default_action, + ..Default::default() + }, + ); + + PsecNetworkPolicy::create( + builder, + &PsecNetworkPolicyArgs { + egress: Some(egress), + ..Default::default() + }, + ) + } } + } - let default_action = match &policy.default_network_policy { - NetworkPolicy::Allow => FilterAction::allow, - NetworkPolicy::Block => FilterAction::deny, + fn uses_process_security_environment(request: &ExecutionRequest) -> bool { + Version::parse(&request.schema_version).is_ok_and(|version| { + let comparable = Version::new(version.major, version.minor, version.patch); + comparable >= Version::new(0, 8, 0) + }) + } + + fn build_process_security_environment_spec(request: &ExecutionRequest) -> Vec { + let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024); + let version = SchemaVersion::new(1, 0); + + let needs_internet_client = Self::needs_internet_client(request); + let capabilities = if request.policy.capabilities.is_empty() && !needs_internet_client { + None + } else { + let mut capabilities = request.policy.capabilities.join(","); + if needs_internet_client { + if !capabilities.is_empty() { + capabilities.push(','); + } + capabilities.push_str("internetClient"); + } + Some(builder.create_string(&capabilities)) }; - let egress = endpoint_policy::create( - builder, - &endpoint_policyArgs { - default_action, - ..Default::default() - }, - ); - FbsNetworkPolicy::create( - builder, - &NetworkPolicyArgs { - egress: Some(egress), - ..Default::default() + let fs_read_write = create_string_vector(&mut builder, &request.policy.readwrite_paths); + let fs_read_only = create_string_vector(&mut builder, &request.policy.readonly_paths); + let fs_deny = create_string_vector(&mut builder, &request.policy.denied_paths); + let network_policy = Some(Self::build_process_security_environment_network_policy( + &mut builder, + &request.policy, + )); + + let ui_restrictions = crate::job_object::to_job_object_uilimit_mask( + &wxc_common::ui_policy::resolve_ui_restrictions( + &request.policy.ui, + &request.policy.base_process_ui, + ), + ) as u64; + + let spec = PsecProcessSecurityEnvironment::create( + &mut builder, + &PsecProcessSecurityEnvironmentArgs { + version: Some(&version), + capabilities, + disallow_win32k_system_calls: request.policy.ui.disable, + ui_restrictions, + fs_read_write, + fs_read_only, + fs_deny, + network_policy, }, - ) + ); + finish_process_security_environment_buffer(&mut builder, spec); + builder.finished_data().to_vec() } /// Build a FlatBuffer `SandboxSpec` from the container policy in the request. @@ -713,73 +827,6 @@ impl BaseContainerRunner { caps } - /// Build the PSEC 1.0 FlatBuffer consumed by - /// `CreateProcessSecurityEnvironment`. - fn build_process_security_environment_spec(request: &ExecutionRequest) -> Vec { - let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024); - let version = SchemaVersion::new(1, 0); - - let needs_internet_client = Self::needs_internet_client(request); - let capabilities = if request.policy.capabilities.is_empty() && !needs_internet_client { - None - } else { - let mut capabilities = request.policy.capabilities.join(","); - if needs_internet_client { - if !capabilities.is_empty() { - capabilities.push(','); - } - capabilities.push_str("internetClient"); - } - Some(builder.create_string(&capabilities)) - }; - - let fs_read_write = create_string_vector(&mut builder, &request.policy.readwrite_paths); - let fs_read_only = create_string_vector(&mut builder, &request.policy.readonly_paths); - let fs_deny = create_string_vector(&mut builder, &request.policy.denied_paths); - - let default_action = match request.policy.default_network_policy { - NetworkPolicy::Allow => FilterAction::allow, - NetworkPolicy::Block => FilterAction::deny, - }; - let egress = EndpointPolicy::create( - &mut builder, - &EndpointPolicyArgs { - default_action, - ..Default::default() - }, - ); - let network_policy = Some(PsecNetworkPolicy::create( - &mut builder, - &PsecNetworkPolicyArgs { - egress: Some(egress), - ..Default::default() - }, - )); - - let ui_restrictions = crate::job_object::to_job_object_uilimit_mask( - &wxc_common::ui_policy::resolve_ui_restrictions( - &request.policy.ui, - &request.policy.base_process_ui, - ), - ) as u64; - - let spec = ProcessSecurityEnvironment::create( - &mut builder, - &ProcessSecurityEnvironmentArgs { - version: Some(&version), - capabilities, - disallow_win32k_system_calls: request.policy.ui.disable, - ui_restrictions, - fs_read_write, - fs_read_only, - fs_deny, - network_policy, - }, - ); - finish_process_security_environment_buffer(&mut builder, spec); - builder.finished_data().to_vec() - } - /// Log the contents of a built sandbox spec FlatBuffer for debug verification. /// /// Reads back token, network, and UI restriction fields from the serialised @@ -1011,7 +1058,8 @@ impl BaseContainerRunner { let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: Build sandbox spec"); let capture_denials = request.policy.capture_denials.clone(); - let spec_bytes = if capture_denials.is_none() { + let use_process_security_environment = Self::uses_process_security_environment(&request); + let spec_bytes = if !use_process_security_environment { let bytes = Self::build_sandbox_spec(&request); Self::log_sandbox_spec(&bytes, logger); Some(bytes) @@ -1022,14 +1070,13 @@ impl BaseContainerRunner { let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: captureDenials"); } - let capture_spec_bytes = capture_denials - .as_ref() - .map(|_| Self::build_process_security_environment_spec(&request)); - if let Some(capture_spec) = capture_spec_bytes.as_ref() { + let process_security_environment_spec = use_process_security_environment + .then(|| Self::build_process_security_environment_spec(&request)); + if let Some(psec_spec) = process_security_environment_spec.as_ref() { let _ = writeln!( logger, "process security environment spec built (PSEC 1.0, {} bytes)", - capture_spec.len() + psec_spec.len() ); } @@ -1056,7 +1103,7 @@ impl BaseContainerRunner { // The normal BaseContainer path uses the SBOX one-shot API. The V2 // capture path uses only the process-security-environment APIs. - let create_process_in_sandbox = if capture_denials.is_none() { + let create_process_in_sandbox = if !use_process_security_environment { let api = match Self::load_api() { Ok(f) => f, Err(e) => return Err(ScriptResponse::error(&e)), @@ -1084,13 +1131,13 @@ impl BaseContainerRunner { cwd_wide.as_ptr() }; - let legacy_destroy_on_exit = capture_denials.is_none() && request.lifecycle.destroy_on_exit; + let legacy_destroy_on_exit = + !use_process_security_environment && request.lifecycle.destroy_on_exit; // Identity applies only to the SBOX one-shot API. PSEC creates and owns // its own AppContainer identity and profile. - // identity so each sandbox gets a unique, cleanable AppContainer profile. // Otherwise we honour whatever the caller passed in (or the default). - let (identity, sid_string) = if capture_denials.is_some() { + let (identity, sid_string) = if use_process_security_environment { ("".to_string(), String::new()) } else if legacy_destroy_on_exit { let ephemeral = sandbox_tracking::generate_sandbox_identity(); @@ -1300,7 +1347,7 @@ impl BaseContainerRunner { // attribute-based CreateProcessW capture path must receive an explicit // clean block or it would inherit all wxc-exec process variables. let env_block: Option> = if request.env.is_empty() { - if capture_denials.is_some() { + if use_process_security_environment { let entries = crate::appcontainer_runner::create_default_env_entries().map_err(|error| { ScriptResponse::error(&format!( @@ -1371,54 +1418,83 @@ impl BaseContainerRunner { let current_env_ptr = env_ptr; let current_creation_flags = creation_flags; - // When captureDenials is active, launch inside a process security - // environment that already has a learning-mode trace started against it, - // instead of the one-shot CreateProcessInSandbox. `begin` creates the - // environment and starts the trace *before* the child launches (so no - // early denials are missed); the environment handle is attached to a - // normal CreateProcessW call via PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT. - // On any early return below, `capture_session` drops and its Drop - // discards the trace and closes the environment (no broker leak). + // Schema 0.8 and later launch through a process security environment. + // captureDenials additionally starts a learning-mode trace before child + // launch; otherwise the environment alone owns the PSEC policy. Both + // owners are RAII guards, so early returns close the environment (and + // discard an unsealed trace) without leaking broker state. let mut capture_session: Option> = None; - if capture_denials.is_some() { - let capture_spec = capture_spec_bytes + let mut security_environment: Option = None; + if use_process_security_environment { + let psec_spec = process_security_environment_spec .as_deref() - .expect("capture spec is initialized with captureDenials"); - match self - .capture_factory - .begin(capture_spec, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE) - { - Ok(session) => { - let _ = writeln!( - logger, - "{CAPTURE_API_AVAILABLE_LOG}; security environment and trace started" - ); - capture_session = Some(session); + .expect("PSEC spec is initialized for schema version 0.8 and later"); + if capture_denials.is_some() { + match self + .capture_factory + .begin(psec_spec, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE) + { + Ok(session) => { + let _ = writeln!( + logger, + "{CAPTURE_API_AVAILABLE_LOG}; security environment and trace started" + ); + capture_session = Some(session); + } + Err(e) => { + let msg = + format!("captureDenials: failed to start learning-mode capture: {e}"); + let _ = writeln!(logger, "Error: {msg}"); + let failure_phase = if learning_mode_api_not_implemented(&e) { + FailurePhase::BackendUnavailable + } else { + FailurePhase::LaunchFailed + }; + self.cleanup_capture_begin_failure(logger); + return Err(ScriptResponse { + exit_code: -1, + error_message: msg.clone(), + standard_err: msg, + failure_phase, + ..Default::default() + }); + } } - Err(e) => { - let msg = format!("captureDenials: failed to start learning-mode capture: {e}"); - let _ = writeln!(logger, "Error: {msg}"); - let failure_phase = if learning_mode_api_not_implemented(&e) { - FailurePhase::BackendUnavailable - } else { - FailurePhase::LaunchFailed - }; - self.cleanup_capture_begin_failure(logger); - return Err(ScriptResponse { - exit_code: -1, - error_message: msg.clone(), - standard_err: msg, - failure_phase, - ..Default::default() - }); + } else { + let result = SecurityEnvironmentApi::load() + .and_then(|api| api.create(psec_spec, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE)); + match result { + Ok(environment) => { + let _ = writeln!( + logger, + "process security environment created (processmodel.dll)" + ); + security_environment = Some(environment); + } + Err(error) => { + let msg = + format!("failed to create the process security environment: {error}"); + let _ = writeln!(logger, "Error: {msg}"); + let failure_phase = if learning_mode_api_not_implemented(&error) { + FailurePhase::BackendUnavailable + } else { + FailurePhase::LaunchFailed + }; + return Err(ScriptResponse { + exit_code: -1, + error_message: msg.clone(), + standard_err: msg, + failure_phase, + ..Default::default() + }); + } } } } // The launch yields (api_return_code, last_win32_error_on_failure). - let (success, last_error, launch_api_name) = if let Some(session) = capture_session.as_ref() - { - // Single-attempt in-environment launch. The learning-mode security + let (success, last_error, launch_api_name) = if use_process_security_environment { + // Single-attempt in-environment launch. The process security // environment is attached as a process-thread attribute; the // CreateProcessInSandbox environment fallback does not apply here. pi = unsafe { std::mem::zeroed() }; @@ -1427,9 +1503,18 @@ impl BaseContainerRunner { } else { Vec::new() }; + let environment_handle = capture_session + .as_ref() + .map(|session| session.environment()) + .or_else(|| { + security_environment + .as_ref() + .map(ProcessSecurityEnvironment::raw) + }) + .expect("PSEC environment owner is initialized before launch"); let extended_startup = match SecurityEnvironmentStartupInfo::new( si, - session.environment(), + environment_handle, &inherited_handles, ) { Ok(startup) => startup, @@ -1439,9 +1524,8 @@ impl BaseContainerRunner { .map(|session| session.finish(None)) .unwrap_or(Ok(())) .err(); - let mut msg = format!( - "captureDenials: failed to attach the process security environment: {primary}" - ); + let mut msg = + format!("failed to attach the process security environment: {primary}"); if let Some(cleanup_error) = &cleanup_error { let _ = write!( msg, @@ -1458,7 +1542,9 @@ impl BaseContainerRunner { } else { FailurePhase::LaunchFailed }; - self.cleanup_capture_begin_failure(logger); + if capture_denials.is_some() { + self.cleanup_capture_begin_failure(logger); + } return Err(ScriptResponse { exit_code: -1, error_message: msg.clone(), @@ -1721,6 +1807,7 @@ impl BaseContainerRunner { sid_string, proxy_coordinator: std::mem::take(&mut self.proxy_coordinator), capture_session, + security_environment, capture_etl_path, capture_output_path, }) @@ -1753,6 +1840,9 @@ struct BaseChild { /// is configured and the OS API is available). Sealed in `run_teardown` /// after the child exits. capture_session: Option>, + /// Non-capture PSEC environment for schema 0.8+ requests. Retained until + /// the child exits so policy enforcement outlives the process tree. + security_environment: Option, /// Internal runner-managed temp `.etl` the broker seals into. Decoded /// then deleted in `run_teardown`. `Some` iff `capture_session` is `Some`. capture_etl_path: Option, @@ -1764,27 +1854,34 @@ struct BaseChild { impl SandboxBackend for BaseContainerRunner { fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { let capture_denials = request.policy.capture_denials.is_some(); - if capture_denials && request.policy.least_privilege_mode { + let use_process_security_environment = Self::uses_process_security_environment(request); + if capture_denials && !use_process_security_environment { return Err(ScriptResponse::error( - "processContainer.captureDenials cannot be combined with \ + "processContainer.captureDenials requires schema version 0.8.0 or later", + )); + } + if use_process_security_environment && request.policy.least_privilege_mode { + return Err(ScriptResponse::error( + "schema version 0.8.0 and later cannot be combined with \ processContainer.leastPrivilege because the Windows process \ security-environment contract does not support LPAC tokens", )); } - if capture_denials && request.policy.network_proxy.is_enabled() { + if use_process_security_environment && request.policy.network_proxy.is_enabled() { return Err(ScriptResponse::error( - "processContainer.captureDenials cannot be combined with network.proxy \ - until the V2 process-security-environment path can supply the required \ + "schema version 0.8.0 and later cannot be combined with network.proxy \ + until the process-security-environment path can supply the required \ proxy AppContainer peer identity", )); } - if capture_denials { + if use_process_security_environment { self.capture_support - .check_apis() + .check_apis(capture_denials) .map_err(|detail| ScriptResponse { failure_phase: FailurePhase::BackendUnavailable, ..ScriptResponse::error(&format!( - "captureDenials requires the official V2 APIs ({detail})" + "schema version 0.8.0 and later requires the official process \ + security-environment APIs ({detail})" )) })?; } @@ -1792,7 +1889,7 @@ impl SandboxBackend for BaseContainerRunner { // through PSEC. Each path has a distinct support query; fail closed // rather than silently dropping the deny policy. if !request.policy.denied_paths.is_empty() { - let deny_supported = if capture_denials { + let deny_supported = if use_process_security_environment { self.capture_support .supports_deny_paths() .map_err(|message| ScriptResponse { @@ -1803,10 +1900,10 @@ impl SandboxBackend for BaseContainerRunner { crate::fallback_detector::base_container_supports_deny_paths() }; if !deny_supported { - return Err(if capture_denials { + return Err(if use_process_security_environment { ScriptResponse { failure_phase: FailurePhase::BackendUnavailable, - ..ScriptResponse::error(CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG) + ..ScriptResponse::error(PSEC_DENIED_PATHS_UNSUPPORTED_MSG) } } else { ScriptResponse::error(wxc_common::error::DENIED_PATHS_FEATURE_DISABLED_MSG) @@ -1818,7 +1915,7 @@ impl SandboxBackend for BaseContainerRunner { wxc_common::error::HOST_LISTS_NOT_SUPPORTED_MSG, )); } - if capture_denials { + if use_process_security_environment { return Ok(()); } @@ -1894,6 +1991,8 @@ struct BaseContainerSandboxProcess { /// Live learning-mode capture session, moved from the `BaseChild`. Sealed /// in `run_teardown` once the child has exited and been reaped. capture_session: Option>, + /// Non-capture PSEC environment, closed after the child exits and is reaped. + security_environment: Option, /// Internal runner-managed temp `.etl` the broker seals into. capture_etl_path: Option, /// Resolved JSON denials deliverable path. @@ -1938,6 +2037,7 @@ impl BaseContainerSandboxProcess { proxy_coordinator: std::mem::take(&mut child.proxy_coordinator), teardown_result: None, capture_session: child.capture_session.take(), + security_environment: child.security_environment.take(), capture_etl_path: child.capture_etl_path.take(), capture_output_path: child.capture_output_path.take(), last_exit_code: None, @@ -1996,6 +2096,7 @@ impl BaseContainerSandboxProcess { } else { Ok(None) }; + self.security_environment.take(); if self.destroy_on_exit { run_sandbox_cleanup( @@ -2426,12 +2527,16 @@ mod tests { deny_error: Option<&'static str>, deny_supported: bool, api_calls: AtomicUsize, + learning_mode_api_calls: AtomicUsize, deny_calls: AtomicUsize, } impl CapturePlatformSupport for FakeCaptureSupport { - fn check_apis(&self) -> Result<(), String> { + fn check_apis(&self, require_learning_mode: bool) -> Result<(), String> { self.api_calls.fetch_add(1, Ordering::SeqCst); + if require_learning_mode { + self.learning_mode_api_calls.fetch_add(1, Ordering::SeqCst); + } self.api_error .map_or(Ok(()), |error| Err(error.to_string())) } @@ -2453,7 +2558,10 @@ mod tests { } fn capture_request_with_denied_path() -> ExecutionRequest { - let mut request = ExecutionRequest::default(); + let mut request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + ..Default::default() + }; request.policy.capture_denials = Some(Default::default()); request.policy.denied_paths = vec![r"C:\secret".to_string()]; request @@ -2865,7 +2973,7 @@ mod tests { .network_policy() .and_then(|policy| policy.egress()) .expect("PSEC must carry an explicit egress default"); - assert_eq!(egress.default_action(), FilterAction::deny); + assert_eq!(egress.default_action(), psec_layout::FilterAction::deny); assert!(egress.allow().is_none()); assert!(egress.deny().is_none()); } @@ -2882,10 +2990,50 @@ mod tests { .and_then(|policy| policy.egress()) .expect("PSEC must carry an explicit egress default"); - assert_eq!(egress.default_action(), FilterAction::allow); + assert_eq!(egress.default_action(), psec_layout::FilterAction::allow); assert_eq!(spec.capabilities(), Some("internetClient")); } + #[test] + fn build_process_security_environment_spec_preserves_proxy_url() { + let mut request = ExecutionRequest::default(); + request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + + let bytes = BaseContainerRunner::build_process_security_environment_spec(&request); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + let network = spec.network_policy().expect("network policy"); + assert_eq!( + network.proxy().and_then(|proxy| proxy.url()), + Some("http://127.0.0.1:8080") + ); + assert!(network.egress().is_none()); + } + + #[test] + fn process_security_environment_routing_uses_schema_version() { + for (version, expected) in [ + ("", false), + ("0.6.0-alpha", false), + ("0.7.99", false), + ("0.8.0-alpha", true), + ("0.8.0", true), + ("1.0.0", true), + ] { + let request = ExecutionRequest { + schema_version: version.to_string(), + ..Default::default() + }; + assert_eq!( + BaseContainerRunner::uses_process_security_environment(&request), + expected, + "schema version {version}" + ); + } + } + #[test] fn build_sandbox_spec_empty_policy() { // Default network policy is Block — no internetClient auto-add. @@ -3148,11 +3296,11 @@ mod tests { #[test] fn capture_denied_paths_error_names_v2_capability() { assert!( - CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG.contains("QueryProcessSecurityEnvironmentSupport") + PSEC_DENIED_PATHS_UNSUPPORTED_MSG.contains("QueryProcessSecurityEnvironmentSupport") ); - assert!(CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG.contains("PSE_SUPPORT_FS_DENY")); - assert!(!CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG.contains("Experimental_QuerySandboxSupport")); - assert!(CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG.contains("cannot fall back to AppContainer")); + assert!(PSEC_DENIED_PATHS_UNSUPPORTED_MSG.contains("PSE_SUPPORT_FS_DENY")); + assert!(!PSEC_DENIED_PATHS_UNSUPPORTED_MSG.contains("Experimental_QuerySandboxSupport")); + assert!(PSEC_DENIED_PATHS_UNSUPPORTED_MSG.contains("cannot fall back to AppContainer")); } #[test] @@ -3163,6 +3311,7 @@ mod tests { deny_error: None, deny_supported: true, api_calls: AtomicUsize::new(0), + learning_mode_api_calls: AtomicUsize::new(0), deny_calls: AtomicUsize::new(0), }); let runner = BaseContainerRunner::with_capture_components(factory.clone(), support.clone()); @@ -3176,6 +3325,7 @@ mod tests { .error_message .contains("missing CloseLearningModeTrace")); assert_eq!(support.api_calls.load(Ordering::SeqCst), 1); + assert_eq!(support.learning_mode_api_calls.load(Ordering::SeqCst), 1); assert_eq!(support.deny_calls.load(Ordering::SeqCst), 0); assert_eq!(factory.begin_calls.load(Ordering::SeqCst), 0); } @@ -3188,6 +3338,7 @@ mod tests { deny_error: Some("query failed"), deny_supported: false, api_calls: AtomicUsize::new(0), + learning_mode_api_calls: AtomicUsize::new(0), deny_calls: AtomicUsize::new(0), }); let runner = BaseContainerRunner::with_capture_components(factory.clone(), support.clone()); @@ -3211,6 +3362,7 @@ mod tests { deny_error: None, deny_supported: false, api_calls: AtomicUsize::new(0), + learning_mode_api_calls: AtomicUsize::new(0), deny_calls: AtomicUsize::new(0), }); let runner = BaseContainerRunner::with_capture_components(factory.clone(), support.clone()); @@ -3220,17 +3372,46 @@ mod tests { .expect_err("missing deny support bit must fail closed"); assert_eq!(error.failure_phase, FailurePhase::BackendUnavailable); - assert_eq!(error.error_message, CAPTURE_DENIED_PATHS_UNSUPPORTED_MSG); + assert_eq!(error.error_message, PSEC_DENIED_PATHS_UNSUPPORTED_MSG); assert_eq!(support.api_calls.load(Ordering::SeqCst), 1); assert_eq!(support.deny_calls.load(Ordering::SeqCst), 1); assert_eq!(factory.begin_calls.load(Ordering::SeqCst), 0); } #[test] - fn validate_runner_rejects_capture_denials_with_least_privilege() { + fn schema_0_8_without_capture_requires_only_security_environment_api() { + let factory = fake_capture_factory(); + let support = Arc::new(FakeCaptureSupport { + api_error: None, + deny_error: None, + deny_supported: true, + api_calls: AtomicUsize::new(0), + learning_mode_api_calls: AtomicUsize::new(0), + deny_calls: AtomicUsize::new(0), + }); + let runner = BaseContainerRunner::with_capture_components(factory.clone(), support.clone()); + let request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + ..Default::default() + }; + + runner + .validate(&request) + .expect("schema 0.8 requires PSEC but not Learning Mode"); + + assert_eq!(support.api_calls.load(Ordering::SeqCst), 1); + assert_eq!(support.learning_mode_api_calls.load(Ordering::SeqCst), 0); + assert_eq!(support.deny_calls.load(Ordering::SeqCst), 0); + assert_eq!(factory.begin_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn validate_runner_rejects_psec_with_least_privilege() { let runner = BaseContainerRunner::new(); - let mut request = ExecutionRequest::default(); - request.policy.capture_denials = Some(Default::default()); + let mut request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + ..Default::default() + }; request.policy.least_privilege_mode = true; let error = runner @@ -3241,10 +3422,12 @@ mod tests { } #[test] - fn validate_runner_rejects_capture_denials_with_proxy() { + fn validate_runner_rejects_psec_with_proxy() { let runner = BaseContainerRunner::new(); - let mut request = ExecutionRequest::default(); - request.policy.capture_denials = Some(Default::default()); + let mut request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + ..Default::default() + }; request.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), builtin_test_server: false, @@ -3252,8 +3435,24 @@ mod tests { let error = runner .validate(&request) - .expect_err("V2 capture proxy requires peer identity plumbing"); + .expect_err("PSEC proxy requires peer identity plumbing"); assert!(error.error_message.contains("network.proxy")); } + + #[test] + fn validate_runner_rejects_capture_denials_before_schema_0_8() { + let runner = BaseContainerRunner::new(); + let mut request = ExecutionRequest { + schema_version: "0.7.0-alpha".to_string(), + ..Default::default() + }; + request.policy.capture_denials = Some(Default::default()); + + let error = runner + .validate(&request) + .expect_err("captureDenials is part of the schema 0.8 PSEC contract"); + + assert!(error.error_message.contains("schema version 0.8.0")); + } } From 873ddb1011e5782696f01d973e985f7298630a61 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 5 Aug 2026 11:01:39 -0700 Subject: [PATCH 10/14] Document schema-based BaseContainer routing Align the PR documentation and code comments with schema 0.8 PSEC routing so non-capture requests are not described as remaining on SBOX. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- .github/copilot-instructions.md | 2 +- docs/process-container/os-version-support.md | 35 +++++++++++-------- .../common/src/base_container_runner.rs | 10 +++--- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 880e462fd..0b476dd56 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -109,7 +109,7 @@ The Rust workspace (`src/`) implements multiple sandboxing backends behind the ` | Backend | Binary | Platform | Module | |---------|--------|----------|--------| | AppContainer | `wxc-exec.exe` | Windows | `backends/appcontainer/common/src/appcontainer_runner.rs` | -| BaseContainer (OS sandbox API) | `wxc-exec.exe` | Windows | `backends/appcontainer/common/src/base_container_runner.rs` — calls `Experimental_CreateProcessInSandbox` via FlatBuffer | +| BaseContainer (OS sandbox API) | `wxc-exec.exe` | Windows | `backends/appcontainer/common/src/base_container_runner.rs` - schema versions through 0.7 call `Experimental_CreateProcessInSandbox` with the SBOX FlatBuffer contract; schema 0.8+ uses `CreateProcessSecurityEnvironment` with the PSEC contract and requires the official V2 exports | | Windows Sandbox | `wxc-exec.exe` | Windows | `backends/windows_sandbox/lifecycle/src/` (live transient one-shot `WindowsSandboxRunner` + state-aware `StatefulSandboxBackend`). Experimental — requires `--experimental`. Supports both **one-shot** (a fresh, disposable VM per invocation with guaranteed teardown, via `ScriptRunner`) and **state-aware** (multi-invocation provision/start/exec/stop/deprovision, via `StatefulSandboxBackend`) modes. State-aware holds a single live VM across separate `wxc-exec` phase processes behind a persistent detached host-side daemon (`backends/windows_sandbox/daemon/`); the OS enforces a single running Windows Sandbox VM per host, so the daemon owns it and reclaims an orphaned VM on restart only via positive process-identity proof. The shared boot sequence (write per-launch nonce, launch VM, capture ownership proof, wait rendezvous, connect) lives in `backends/windows_sandbox/lifecycle/src/vm.rs::launch_managed_vm`; each mode plugs in its own `LaunchObserver` for the per-caller ownership / proof bookkeeping. Honors `readwritePaths`/`readonlyPaths`/`deniedPaths` (HOST paths) at provision via `.wsb` `` entries (mapped at the same absolute host path inside the guest; rejects `deniedPaths` equal-to or nested-within a mapped share since `.wsb` has no Deny primitive); filesystem policy is immutable post-provision. Network isolation is enforced unconditionally by the in-guest agent; `network`/`ui` and the Entra `user` bundle are not honored. ID prefix `wsb` (strict `wsb:<8-hex>` grammar). Per-launch handshake: 32-byte `Nonce` + 1-byte `ChannelRole` tag on every TCP connection (boot + reconnect); the guest pairs accepted sockets by declared role, not by accept order. The guest agent binary `wxc-windows-sandbox-guest.exe` (`backends/windows_sandbox/guest/`) is injected into the VM. | | MicroVM (NanVix) | `wxc-exec.exe` | Windows | `backends/nanvix/runner/src/lib.rs` — feature-gated behind `microvm` | | Hyperlight | `wxc-exec.exe` | Windows | `backends/hyperlight/common/src/lib.rs` — Hyperlight + Unikraft micro-VM backend | diff --git a/docs/process-container/os-version-support.md b/docs/process-container/os-version-support.md index 0103755ff..21e807991 100644 --- a/docs/process-container/os-version-support.md +++ b/docs/process-container/os-version-support.md @@ -49,28 +49,33 @@ available bounds what policy can be enforced. - **T3 (AppContainer + DACL)** is the universal fallback and enforces filesystem policy via host path ACEs on every release. -## Learning Mode denial capture +## Schema 0.8 process security environment -`processContainer.captureDenials` uses a separate V2 process -security-environment path rather than the T1/T2/T3 fallback chain. The host -must expose the complete official V2 export set: +BaseContainer requests using schema versions through 0.7 use the SBOX contract +and the T1/T2/T3 fallback chain above. Schema 0.8 and later use the PSEC +process-security-environment contract, even when `captureDenials` is absent, +and do not fall back to SBOX or AppContainer. The host must expose: -- `StartLearningModeTrace` -- `StopLearningModeTrace` -- `CloseLearningModeTrace` - `CreateProcessSecurityEnvironment` - `QueryProcessSecurityEnvironmentSupport` - `CloseProcessSecurityEnvironment` -Unsupported or earlier-contract hosts fail as `backend_unavailable`; capture -never falls back to AppContainer or host-DACL enforcement. Internal validation -confirmed the earlier contract on build `26657.1002` is rejected and the full -V2 contract on build `26663.1000` is accepted. These builds are validation -points, not a public release-floor commitment; runtime export probing is the -source of truth. +When `processContainer.captureDenials` is present, the host must additionally +expose the complete official V2 Learning Mode export set: + +- `StartLearningModeTrace` +- `StopLearningModeTrace` +- `CloseLearningModeTrace` + +Unsupported or earlier-contract hosts fail as `backend_unavailable`. Internal +validation confirmed the earlier contract on build `26657.1002` is rejected +and the full V2 contract on build `26663.1000` is accepted. These builds are +validation points, not a public release-floor commitment; runtime export +probing is the source of truth. -Capture is incompatible with `processContainer.leastPrivilege` and -`network.proxy`. `filesystem.deniedPaths` is accepted only when +The PSEC contract cannot represent `processContainer.leastPrivilege`, and MXC +does not yet supply the peer identity required by its proxy contract, so schema +0.8+ rejects both options. `filesystem.deniedPaths` is accepted only when `QueryProcessSecurityEnvironmentSupport` advertises `PSE_SUPPORT_FS_DENY`. ## Filesystem policy diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index ce62baed2..e4d76ad5b 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -1101,8 +1101,8 @@ impl BaseContainerRunner { let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: Load API"); - // The normal BaseContainer path uses the SBOX one-shot API. The V2 - // capture path uses only the process-security-environment APIs. + // Schema versions through 0.7 use the SBOX one-shot API. Schema 0.8+ + // uses only the process-security-environment APIs. let create_process_in_sandbox = if !use_process_security_environment { let api = match Self::load_api() { Ok(f) => f, @@ -1885,9 +1885,9 @@ impl SandboxBackend for BaseContainerRunner { )) })?; } - // deniedPaths reaches ordinary BaseContainer through SBOX and capture - // through PSEC. Each path has a distinct support query; fail closed - // rather than silently dropping the deny policy. + // deniedPaths reaches schema <=0.7 BaseContainer through SBOX and + // schema 0.8+ through PSEC. Each path has a distinct support query; + // fail closed rather than silently dropping the deny policy. if !request.policy.denied_paths.is_empty() { let deny_supported = if use_process_security_environment { self.capture_support From 5222015e04c0c5b7942286fa5d3142a98d00292f Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 5 Aug 2026 11:13:09 -0700 Subject: [PATCH 11/14] Bypass legacy fallback for schema 0.8 Route all schema 0.8+ BaseContainer requests directly to PSEC before tier detection, including non-capture requests, and cover forced legacy fallback with a regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- .../common/src/base_container_runner.rs | 2 +- .../appcontainer/common/src/dispatcher.rs | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index e4d76ad5b..25b107629 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -653,7 +653,7 @@ impl BaseContainerRunner { } } - fn uses_process_security_environment(request: &ExecutionRequest) -> bool { + pub(crate) fn uses_process_security_environment(request: &ExecutionRequest) -> bool { Version::parse(&request.schema_version).is_ok_and(|version| { let comparable = Version::new(version.major, version.minor, version.patch); comparable >= Version::new(0, 8, 0) diff --git a/src/backends/appcontainer/common/src/dispatcher.rs b/src/backends/appcontainer/common/src/dispatcher.rs index 3f98229ef..4cf41bc6f 100644 --- a/src/backends/appcontainer/common/src/dispatcher.rs +++ b/src/backends/appcontainer/common/src/dispatcher.rs @@ -338,9 +338,9 @@ fn select_backend_with_fallback( ), DispatchError, > { - // captureDenials uses the official V2 process-security-environment API, + // Schema 0.8+ uses the official process-security-environment API, // independently of the legacy SBOX tier probe/fallback chain. - if request.policy.capture_denials.is_some() { + if BaseContainerRunner::uses_process_security_environment(request) { return Ok(( SelectedBackend::BaseContainer(BaseContainerRunner::new()), None, @@ -627,6 +627,13 @@ mod tests { } } + fn schema_0_8_request(policy: ContainerPolicy) -> ExecutionRequest { + ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + ..test_request(policy) + } + } + fn empty_policy() -> ContainerPolicy { ContainerPolicy::default() } @@ -698,7 +705,7 @@ mod tests { let _g = ForceTierGuard::set("appcontainer-dacl"); let (mut policy, _tmp) = policy_with_rw_temp(); policy.capture_denials = Some(Default::default()); - let req = test_request(policy); + let req = schema_0_8_request(policy); let dispatched = dispatch_with_fallback(&req).expect("V2 backend should be selected"); assert!(matches!(dispatched.tier, IsolationTier::BaseContainer)); @@ -707,6 +714,20 @@ mod tests { "V2 capture must never apply legacy host DACLs" ); } + + #[test] + fn schema_0_8_without_capture_selects_psec_without_legacy_fallback() { + let _g = ForceTierGuard::set("appcontainer-dacl"); + let (policy, _tmp) = policy_with_rw_temp(); + let req = schema_0_8_request(policy); + + let dispatched = dispatch_with_fallback(&req).expect("PSEC backend should be selected"); + assert!(matches!(dispatched.tier, IsolationTier::BaseContainer)); + assert!( + !dispatched.has_dacl_guard(), + "schema 0.8+ must bypass the legacy fallback and DACL paths" + ); + } #[test] fn dispatch_fallback_disabled_errors() { let _g = ForceTierGuard::set("appcontainer-dacl"); From 7436f529c49b70f21dacc92df7fda8626989602b Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 5 Aug 2026 11:51:11 -0700 Subject: [PATCH 12/14] Skip host API probes during dry-run Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- .../common/src/base_container_runner.rs | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index 25b107629..0c6a32fac 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -1874,6 +1874,16 @@ impl SandboxBackend for BaseContainerRunner { proxy AppContainer peer identity", )); } + if !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty() { + return Err(ScriptResponse::error( + wxc_common::error::HOST_LISTS_NOT_SUPPORTED_MSG, + )); + } + // Dry-run validates the schema and policy shape without requiring the + // current host to expose the selected schema's OS APIs. + if request.dry_run { + return Ok(()); + } if use_process_security_environment { self.capture_support .check_apis(capture_denials) @@ -1910,11 +1920,6 @@ impl SandboxBackend for BaseContainerRunner { }); } } - if !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty() { - return Err(ScriptResponse::error( - wxc_common::error::HOST_LISTS_NOT_SUPPORTED_MSG, - )); - } if use_process_security_environment { return Ok(()); } @@ -3260,6 +3265,7 @@ mod tests { fn validate_runner_rejects_allowed_hosts() { let runner = BaseContainerRunner::new(); let mut request = ExecutionRequest::default(); + request.dry_run = true; request.policy.allowed_hosts = vec!["example.com".into()]; let err = runner @@ -3272,6 +3278,7 @@ mod tests { fn validate_runner_rejects_blocked_hosts() { let runner = BaseContainerRunner::new(); let mut request = ExecutionRequest::default(); + request.dry_run = true; request.policy.blocked_hosts = vec!["bad.example.com".into()]; let err = runner @@ -3405,11 +3412,42 @@ mod tests { assert_eq!(factory.begin_calls.load(Ordering::SeqCst), 0); } + #[test] + fn schema_0_8_dry_run_skips_host_api_probes() { + let factory = fake_capture_factory(); + let support = Arc::new(FakeCaptureSupport { + api_error: Some("V2 exports unavailable"), + deny_error: Some("deny support query unavailable"), + deny_supported: false, + api_calls: AtomicUsize::new(0), + learning_mode_api_calls: AtomicUsize::new(0), + deny_calls: AtomicUsize::new(0), + }); + let runner = BaseContainerRunner::with_capture_components(factory.clone(), support.clone()); + let mut request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + dry_run: true, + ..Default::default() + }; + request.policy.capture_denials = Some(Default::default()); + request.policy.denied_paths = vec![r"C:\secret".to_string()]; + + runner + .validate(&request) + .expect("dry-run should validate policy without probing host APIs"); + + assert_eq!(support.api_calls.load(Ordering::SeqCst), 0); + assert_eq!(support.learning_mode_api_calls.load(Ordering::SeqCst), 0); + assert_eq!(support.deny_calls.load(Ordering::SeqCst), 0); + assert_eq!(factory.begin_calls.load(Ordering::SeqCst), 0); + } + #[test] fn validate_runner_rejects_psec_with_least_privilege() { let runner = BaseContainerRunner::new(); let mut request = ExecutionRequest { schema_version: "0.8.0-alpha".to_string(), + dry_run: true, ..Default::default() }; request.policy.least_privilege_mode = true; @@ -3426,6 +3464,7 @@ mod tests { let runner = BaseContainerRunner::new(); let mut request = ExecutionRequest { schema_version: "0.8.0-alpha".to_string(), + dry_run: true, ..Default::default() }; request.policy.network_proxy = ProxyConfig { @@ -3445,6 +3484,7 @@ mod tests { let runner = BaseContainerRunner::new(); let mut request = ExecutionRequest { schema_version: "0.7.0-alpha".to_string(), + dry_run: true, ..Default::default() }; request.policy.capture_denials = Some(Default::default()); From b9368ff0e2605a6dcafc9053e779f9342aebb4cf Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 5 Aug 2026 12:01:41 -0700 Subject: [PATCH 13/14] Fix dry-run validation test lint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- .../appcontainer/common/src/base_container_runner.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index 0c6a32fac..629c38dd7 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -3264,8 +3264,10 @@ mod tests { #[test] fn validate_runner_rejects_allowed_hosts() { let runner = BaseContainerRunner::new(); - let mut request = ExecutionRequest::default(); - request.dry_run = true; + let mut request = ExecutionRequest { + dry_run: true, + ..Default::default() + }; request.policy.allowed_hosts = vec!["example.com".into()]; let err = runner @@ -3277,8 +3279,10 @@ mod tests { #[test] fn validate_runner_rejects_blocked_hosts() { let runner = BaseContainerRunner::new(); - let mut request = ExecutionRequest::default(); - request.dry_run = true; + let mut request = ExecutionRequest { + dry_run: true, + ..Default::default() + }; request.policy.blocked_hosts = vec!["bad.example.com".into()]; let err = runner From 6961b4af3fa5e80f05dee7bb8ee77c94e540c680 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 5 Aug 2026 19:03:09 -0700 Subject: [PATCH 14/14] Restore proxy compatibility fallback Route proxy requests away from capability-aware SBOX contracts until MXC can author the required AppContainer peer identity. Add the official security-environment API-set probe and retain legacy SBOX proxy behavior on query-less hosts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- .github/copilot-instructions.md | 2 +- docs/process-container/os-version-support.md | 54 ++- src/Cargo.toml | 2 +- .../common/src/base_container_runner.rs | 437 ++++++++++++++++-- .../appcontainer/common/src/dispatcher.rs | 88 +++- .../common/src/fallback_detector.rs | 37 +- src/backends/learning_mode/windows/src/lib.rs | 10 + .../learning_mode/windows/src/secenv.rs | 26 +- 8 files changed, 556 insertions(+), 100 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ae0a781d2..f8c54b4d5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -109,7 +109,7 @@ The Rust workspace (`src/`) implements multiple sandboxing backends behind the ` | Backend | Binary | Platform | Module | |---------|--------|----------|--------| | AppContainer | `wxc-exec.exe` | Windows | `backends/appcontainer/common/src/appcontainer_runner.rs` | -| BaseContainer (OS sandbox API) | `wxc-exec.exe` | Windows | `backends/appcontainer/common/src/base_container_runner.rs` — schema versions through 0.7 call `Experimental_CreateProcessInSandbox` with the SBOX FlatBuffer contract; schema 0.8+ uses `CreateProcessSecurityEnvironment` with the PSEC contract and requires the official V2 exports | +| BaseContainer (OS sandbox API) | `wxc-exec.exe` | Windows | `backends/appcontainer/common/src/base_container_runner.rs` — schema versions through 0.7 call `Experimental_CreateProcessInSandbox` with the SBOX FlatBuffer contract. Schema 0.8+ prefers `CreateProcessSecurityEnvironment` with PSEC when its runtime probe succeeds, temporarily falls back to SBOX when PSEC is unavailable, then retains the AppContainer tier fallback. Proxy requests use legacy SBOX only on query-less hosts; capability-aware SBOX hosts fall back to AppContainer until MXC can author the model-2 AppContainer-peer contract. `captureDenials` still requires the official V2 PSEC + Learning Mode exports and cannot use a lower tier. | | Windows Sandbox | `wxc-exec.exe` | Windows | `backends/windows_sandbox/lifecycle/src/` (live transient one-shot `WindowsSandboxRunner` + state-aware `StatefulSandboxBackend`). Experimental — requires `--experimental`. Supports both **one-shot** (a fresh, disposable VM per invocation with guaranteed teardown, via `ScriptRunner`) and **state-aware** (multi-invocation provision/start/exec/stop/deprovision, via `StatefulSandboxBackend`) modes. State-aware holds a single live VM across separate `wxc-exec` phase processes behind a persistent detached host-side daemon (`backends/windows_sandbox/daemon/`); the OS enforces a single running Windows Sandbox VM per host, so the daemon owns it and reclaims an orphaned VM on restart only via positive process-identity proof. The shared boot sequence (write per-launch nonce, launch VM, capture ownership proof, wait rendezvous, connect) lives in `backends/windows_sandbox/lifecycle/src/vm.rs::launch_managed_vm`; each mode plugs in its own `LaunchObserver` for the per-caller ownership / proof bookkeeping. Honors `readwritePaths`/`readonlyPaths`/`deniedPaths` (HOST paths) at provision via `.wsb` `` entries (mapped at the same absolute host path inside the guest; rejects `deniedPaths` equal-to or nested-within a mapped share since `.wsb` has no Deny primitive); filesystem policy is immutable post-provision. Network isolation is enforced unconditionally by the in-guest agent; `network`/`ui` and the Entra `user` bundle are not honored. ID prefix `wsb` (strict `wsb:<8-hex>` grammar). Per-launch handshake: 32-byte `Nonce` + 1-byte `ChannelRole` tag on every TCP connection (boot + reconnect); the guest pairs accepted sockets by declared role, not by accept order. The guest agent binary `wxc-windows-sandbox-guest.exe` (`backends/windows_sandbox/guest/`) is injected into the VM. | | MicroVM (NanVix) | `wxc-exec.exe` | Windows | `backends/nanvix/runner/src/lib.rs` — feature-gated behind `microvm` | | Hyperlight | `wxc-exec.exe` | Windows | `backends/hyperlight/common/src/lib.rs` — Hyperlight + Unikraft micro-VM backend | diff --git a/docs/process-container/os-version-support.md b/docs/process-container/os-version-support.md index 21e807991..a3f663d96 100644 --- a/docs/process-container/os-version-support.md +++ b/docs/process-container/os-version-support.md @@ -49,34 +49,48 @@ available bounds what policy can be enforced. - **T3 (AppContainer + DACL)** is the universal fallback and enforces filesystem policy via host path ACEs on every release. -## Schema 0.8 process security environment +## Schema 0.8 process security environment preference BaseContainer requests using schema versions through 0.7 use the SBOX contract -and the T1/T2/T3 fallback chain above. Schema 0.8 and later use the PSEC -process-security-environment contract, even when `captureDenials` is absent, -and do not fall back to SBOX or AppContainer. The host must expose: +and the T1/T2/T3 fallback chain above. Schema 0.8 and later prefer the PSEC +process-security-environment contract when its complete export set resolves and +`QueryProcessSecurityEnvironmentSupport` succeeds. During the transition from +the experimental SBOX API to PSEC, an ordinary schema 0.8 request falls back to +SBOX when PSEC is unavailable, then continues through the existing AppContainer +fallback tiers when neither BaseContainer contract is usable. + +The PSEC probe requires: - `CreateProcessSecurityEnvironment` - `QueryProcessSecurityEnvironmentSupport` - `CloseProcessSecurityEnvironment` -When `processContainer.captureDenials` is present, the host must additionally +When `processContainer.captureDenials` is present, fallback is not possible: +capture requires a PSEC handle to key the trace. The host must additionally expose the complete official V2 Learning Mode export set: - `StartLearningModeTrace` - `StopLearningModeTrace` - `CloseLearningModeTrace` -Unsupported or earlier-contract hosts fail as `backend_unavailable`. Internal -validation confirmed the earlier contract on build `26657.1002` is rejected -and the full V2 contract on build `26663.1000` is accepted. These builds are -validation points, not a public release-floor commitment; runtime export +For capture, unsupported or earlier-contract hosts fail as +`backend_unavailable`. Ordinary ProcessContainer execution still follows the +fallback chain. Internal validation confirmed the earlier contract on build +`26657.1002` is rejected for capture while schema 0.7 SBOX execution remains +functional, and the full V2 contract on build `26663.1000` is accepted. These +builds are validation points, not a public release-floor commitment; runtime probing is the source of truth. -The PSEC contract cannot represent `processContainer.leastPrivilege`, and MXC -does not yet supply the peer identity required by its proxy contract, so schema -0.8+ rejects both options. `filesystem.deniedPaths` is accepted only when -`QueryProcessSecurityEnvironmentSupport` advertises `PSE_SUPPORT_FS_DENY`. +The PSEC contract cannot represent `processContainer.leastPrivilege`, so +ordinary schema 0.8 requests using that option use the transitional SBOX +contract instead of failing. MXC also does not yet supply the AppContainer peer +identity required by the current model-2 SBOX proxy contract. On hosts with +`Experimental_QuerySandboxSupport`, proxy requests therefore skip +BaseContainer and continue to the AppContainer fallback; older query-less hosts +retain the legacy SBOX proxy path. Similarly, `filesystem.deniedPaths` uses +PSEC only when `QueryProcessSecurityEnvironmentSupport` advertises +`PSE_SUPPORT_FS_DENY`; otherwise MXC continues through the SBOX/AppContainer +fallback chain. ## Filesystem policy @@ -103,17 +117,19 @@ Notes: |--------|:--:|:--:|:--:|:--:| | Capabilities (`internetClient`) | ✅ | ✅ | ✅ | ✅ | | Firewall rules (`netsh advfirewall`, needs admin) | ✅ | ✅ | ✅ | ✅ | -| Proxy via OS / BaseContainer (`appinfosvc`, FlatBuffer `network_policy.proxy`) | ❌ | ❌ | ❌ | ✅ (T1 only) | +| Proxy | ✅ (AppContainer compatibility) | ✅ (AppContainer compatibility) | ✅ (AppContainer compatibility) | ✅ (legacy T1 or AppContainer compatibility) | Notes: - Capability- and firewall-based network enforcement is an AppContainer primitive and works on every release. - OS-configured WinHTTP proxy (passed in the FlatBuffer spec to - `CreateProcessInSandbox`) is a T1-only path and therefore 25H2+ only. -- The earlier AppContainer WinHTTP proxy shim (`winhttp-proxy-shim.exe`) is - being retired and is intentionally omitted here: the new WinHTTP cleanup APIs - it depended on are not moving down-level, so it is not a forward-looking - option. + `CreateProcessInSandbox`) is used only on legacy query-less T1 hosts. The + capability-aware model-2 contract requires an AppContainer proxy peer + identity that MXC does not yet author, so those hosts use the AppContainer + compatibility fallback. +- The AppContainer compatibility path uses `winhttp-proxy-shim.exe`. It is not + the forward-looking proxy architecture; support for the model-2 BaseContainer + contract should replace this fallback in a separate change. ## UI restrictions diff --git a/src/Cargo.toml b/src/Cargo.toml index 2cca1a38a..767e5e4c4 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -92,7 +92,7 @@ windows = { version = "0.62", features = [ "Win32_System_SystemInformation", "Win32_System_Time", "Win32_System_SystemServices", - "Win32_System_SystemInformation", + "Win32_System_WindowsProgramming", "Win32_System_JobObjects", ] } windows-core = "0.62" diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index 629c38dd7..00de69dde 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -4,9 +4,10 @@ //! `BaseContainerRunner` — executes scripts through the Windows BaseContainer APIs. //! //! Schema versions through 0.7 use the legacy `SandboxSpec` / one-shot -//! `Experimental_CreateProcessInSandbox` path. Schema 0.8 and later use the +//! `Experimental_CreateProcessInSandbox` path. Schema 0.8 and later prefer the //! PSEC 1.0 / `CreateProcessSecurityEnvironment` two-phase contract and attach -//! the resulting environment to `CreateProcessW`. +//! the resulting environment to `CreateProcessW`, but temporarily fall back to +//! the legacy SBOX contract when PSEC is unavailable. use std::ffi::c_void; use std::fmt::Write; @@ -25,8 +26,8 @@ use learning_mode_windows::{ use semver::Version; use windows::Win32::Foundation::{ - CloseHandle, GetLastError, SetHandleInformation, ERROR_CALL_NOT_IMPLEMENTED, E_NOTIMPL, HANDLE, - HANDLE_FLAG_INHERIT, WAIT_OBJECT_0, WAIT_TIMEOUT, + CloseHandle, GetLastError, SetHandleInformation, ERROR_CALL_NOT_IMPLEMENTED, + ERROR_NOT_SUPPORTED, E_NOTIMPL, HANDLE, HANDLE_FLAG_INHERIT, WAIT_OBJECT_0, WAIT_TIMEOUT, }; use windows::Win32::System::Console::{ GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, @@ -228,6 +229,10 @@ const SANDBOX_CAP_CREATE_PROCESS_IN_SANDBOX: u64 = 0x0000_0000_0000_0001; /// assumed bit 1). When clear, `deniedPaths` is rejected at launch and callers /// must rely on default-deny plus explicit `readwrite`/`readonly` grants. const SANDBOX_CAP_FS_DENY: u64 = 0x0000_0000_0000_0002; +/// `SANDBOX_CAP_NETWORK_PROXY`: when set, SBOX uses the model-2 proxy +/// contract, which requires an AppContainer proxy peer identity that MXC does +/// not yet provide. +const SANDBOX_CAP_NETWORK_PROXY: u64 = 0x0000_0000_0000_0004; const CAPTURE_API_AVAILABLE_LOG: &str = "captureDenials: learning-mode trace API available (processmodel.dll)"; const PSEC_DENIED_PATHS_UNSUPPORTED_MSG: &str = @@ -245,9 +250,25 @@ fn is_api_not_implemented(err: u32) -> bool { err == ERROR_CALL_NOT_IMPLEMENTED.0 || err == E_NOTIMPL.0 as u32 } +/// The schema 0.8 proxy compatibility path deliberately selects transitional +/// SBOX because PSEC cannot yet supply the proxy peer identity. If that older +/// contract reports `ERROR_NOT_SUPPORTED`, expose it as backend availability +/// without changing error classification for unrelated SBOX policies. +fn is_schema_0_8_proxy_fallback_unavailable( + err: u32, + request: &ExecutionRequest, + use_process_security_environment: bool, +) -> bool { + err == ERROR_NOT_SUPPORTED.0 + && !use_process_security_environment + && BaseContainerRunner::schema_prefers_process_security_environment(request) + && request.policy.network_proxy.is_enabled() +} + fn learning_mode_api_not_implemented(error: &learning_mode_windows::LearningModeError) -> bool { match error { - learning_mode_windows::LearningModeError::DllLoad(_) + learning_mode_windows::LearningModeError::ApiSetUnavailable { .. } + | learning_mode_windows::LearningModeError::DllLoad(_) | learning_mode_windows::LearningModeError::ExportMissing { .. } => true, learning_mode_windows::LearningModeError::HResultCall { code, .. } => *code == E_NOTIMPL.0, learning_mode_windows::LearningModeError::ApiCall { code, .. } => { @@ -338,12 +359,21 @@ enum ResolvedNetworkPolicy<'a> { Egress(&'a NetworkPolicy), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SboxProxyContract { + LegacyOrUnknown, + Unavailable, + Model2PeerIdentity, +} + /// Script runner that uses `Experimental_CreateProcessInSandbox` API /// to launch a sandboxed process. pub struct BaseContainerRunner { proxy_coordinator: ProxyCoordinator, capture_factory: Arc, capture_support: Arc, + #[cfg(test)] + psec_usable_override: Option, } impl Default for BaseContainerRunner { @@ -352,6 +382,8 @@ impl Default for BaseContainerRunner { proxy_coordinator: ProxyCoordinator::default(), capture_factory: Arc::new(RealCaptureSessionFactory), capture_support: Arc::new(RealCapturePlatformSupport), + #[cfg(test)] + psec_usable_override: None, } } } @@ -387,6 +419,7 @@ impl BaseContainerRunner { proxy_coordinator: ProxyCoordinator::default(), capture_factory, capture_support: Arc::new(RealCapturePlatformSupport), + psec_usable_override: Some(true), } } @@ -399,6 +432,7 @@ impl BaseContainerRunner { proxy_coordinator: ProxyCoordinator::default(), capture_factory, capture_support, + psec_usable_override: Some(true), } } @@ -425,10 +459,53 @@ impl BaseContainerRunner { /// symbol-present? Resolves enablement up front so tier selection /// never picks a Tier 1 that cannot launch: /// - /// 1. `Experimental_QuerySandboxSupport`, when present, is authoritative. - /// 2. Otherwise, probe the create API itself (older builds lack the query). - /// 3. If even the create symbol is absent, the OS is down-level. + /// 1. Probe the PSEC create/close contract. + /// 2. Otherwise query transitional SBOX support when available. + /// 3. Otherwise probe the SBOX create API itself. pub fn is_base_container_usable() -> bool { + #[cfg(test)] + if let Ok(forced) = std::env::var("MXC_FORCE_BC_USABLE") { + return forced == "1"; + } + if Self::is_process_security_environment_usable() { + return true; + } + Self::is_legacy_base_container_usable() + } + + /// Whether PSEC can create and close a minimal security environment on + /// this host. Export presence and the support query alone are insufficient + /// on transitional builds where the API surface exists before the feature + /// is enabled. + pub fn is_process_security_environment_usable() -> bool { + static USABLE: std::sync::OnceLock = std::sync::OnceLock::new(); + *USABLE.get_or_init(|| { + let request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + ..Default::default() + }; + let specification = Self::build_process_security_environment_spec(&request); + SecurityEnvironmentApi::load() + .and_then(|api| api.create(&specification, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE)) + .and_then(|environment| { + let startup_info = SecurityEnvironmentStartupInfo::new( + STARTUPINFOW::default(), + environment.raw(), + &[], + ); + environment.close(); + startup_info.map(drop) + }) + .is_ok() + }) + } + + /// Whether the transitional SBOX BaseContainer contract is usable. + fn is_legacy_base_container_usable() -> bool { + #[cfg(test)] + if let Ok(forced) = std::env::var("MXC_FORCE_BC_USABLE") { + return forced == "1"; + } match Self::query_sandbox_create_capability() { Some(enabled) => enabled, None => Self::probe_create_process_feature_enabled(), @@ -445,14 +522,8 @@ impl BaseContainerRunner { /// rejected at launch. Tier 3 (AppContainer + DACL) enforces `deniedPaths` /// via DENY ACEs independently of this bit. pub fn base_container_supports_deny_paths() -> bool { - let Some(query) = Self::load_query_sandbox_support() else { - return false; - }; - let mut capabilities: u64 = 0; - // SAFETY: `query` is the resolved export; `capabilities` is a valid - // out-param. - let succeeded = unsafe { query(&mut capabilities) }; - Self::decode_deny_capability(succeeded, capabilities) + Self::query_sandbox_capabilities() + .is_some_and(|capabilities| Self::decode_deny_capability(1, capabilities)) } /// Decode a `QuerySandboxSupport` result for the deny-paths capability. @@ -468,6 +539,13 @@ impl BaseContainerRunner { /// itself failed), so the caller must probe another way rather than assume /// "unusable". fn query_sandbox_create_capability() -> Option { + Self::query_sandbox_capabilities() + .map(|capabilities| Self::decode_create_capability(1, capabilities)) + } + + /// Query the capability-aware SBOX contract. `None` means the export is + /// absent or the query failed, so callers must use the legacy probe path. + fn query_sandbox_capabilities() -> Option { let query = Self::load_query_sandbox_support()?; let mut capabilities: u64 = 0; // SAFETY: `query` is the resolved export; `capabilities` is a valid @@ -479,7 +557,7 @@ impl BaseContainerRunner { if ok == 0 { return None; } - Some(Self::decode_create_capability(ok, capabilities)) + Some(capabilities) } /// Decode a `QuerySandboxSupport` result: the create-process capability is @@ -488,6 +566,38 @@ impl BaseContainerRunner { ok != 0 && (capabilities & SANDBOX_CAP_CREATE_PROCESS_IN_SANDBOX) != 0 } + /// Whether the request can use the legacy SBOX contract selected by MXC. + /// + /// A successful support query identifies the capability-aware OS contract: + /// with `SANDBOX_CAP_NETWORK_PROXY` clear, proxy is unavailable; with it + /// set, proxy requires `allowed_appcontainer_peer` and an AppContainer-hosted + /// proxy. MXC supports neither shape yet, so proxy requests use the + /// AppContainer fallback. Query-less builds retain the older SBOX proxy + /// behavior. + fn legacy_sbox_compatible_with_request( + request: &ExecutionRequest, + queried_capabilities: Option, + ) -> bool { + if !request.policy.network_proxy.is_enabled() { + return true; + } + + matches!( + Self::decode_sbox_proxy_contract(queried_capabilities), + SboxProxyContract::LegacyOrUnknown + ) + } + + fn decode_sbox_proxy_contract(queried_capabilities: Option) -> SboxProxyContract { + match queried_capabilities { + None => SboxProxyContract::LegacyOrUnknown, + Some(capabilities) if (capabilities & SANDBOX_CAP_NETWORK_PROXY) != 0 => { + SboxProxyContract::Model2PeerIdentity + } + Some(_) => SboxProxyContract::Unavailable, + } + } + /// Resolve `Experimental_QuerySandboxSupport`; `None` if not present. fn load_query_sandbox_support() -> Option { let dll_name = string_util::to_wide("processmodel.dll"); @@ -653,13 +763,88 @@ impl BaseContainerRunner { } } - pub(crate) fn uses_process_security_environment(request: &ExecutionRequest) -> bool { + pub(crate) fn schema_prefers_process_security_environment(request: &ExecutionRequest) -> bool { Version::parse(&request.schema_version).is_ok_and(|version| { let comparable = Version::new(version.major, version.minor, version.patch); comparable >= Version::new(0, 8, 0) }) } + fn should_use_process_security_environment( + request: &ExecutionRequest, + psec_usable: bool, + psec_supports_deny_paths: bool, + ) -> bool { + if !Self::schema_prefers_process_security_environment(request) || !psec_usable { + return false; + } + if request.policy.capture_denials.is_some() { + return true; + } + !request.policy.least_privilege_mode + && !request.policy.network_proxy.is_enabled() + && (request.policy.denied_paths.is_empty() || psec_supports_deny_paths) + } + + fn process_security_environment_usable(&self) -> bool { + #[cfg(test)] + if let Some(usable) = self.psec_usable_override { + return usable; + } + Self::is_process_security_environment_usable() + } + + fn uses_process_security_environment(&self, request: &ExecutionRequest) -> bool { + let supports_deny_paths = request.policy.capture_denials.is_some() + || request.policy.denied_paths.is_empty() + || self.capture_support.supports_deny_paths().unwrap_or(false); + Self::should_use_process_security_environment( + request, + self.process_security_environment_usable(), + supports_deny_paths, + ) + } + + pub(crate) fn is_usable_for_request(request: &ExecutionRequest) -> bool { + #[cfg(test)] + if let Ok(forced) = std::env::var("MXC_FORCE_BC_USABLE") { + return forced == "1"; + } + let psec_usable = Self::is_process_security_environment_usable(); + if request.policy.capture_denials.is_some() { + return Self::schema_prefers_process_security_environment(request) && psec_usable; + } + let psec_supports_deny_paths = request.policy.denied_paths.is_empty() + || SecurityEnvironmentApi::load() + .and_then(|api| api.supports_deny_paths()) + .unwrap_or(false); + if Self::should_use_process_security_environment( + request, + psec_usable, + psec_supports_deny_paths, + ) { + return true; + } + if !Self::legacy_sbox_compatible_with_request(request, Self::query_sandbox_capabilities()) { + return false; + } + Self::is_legacy_base_container_usable() + } + + pub(crate) fn supports_deny_paths_for_request(request: &ExecutionRequest) -> bool { + let psec_supports_deny_paths = SecurityEnvironmentApi::load() + .and_then(|api| api.supports_deny_paths()) + .unwrap_or(false); + if Self::should_use_process_security_environment( + request, + Self::is_process_security_environment_usable(), + psec_supports_deny_paths, + ) { + return true; + } + crate::fallback_detector::base_container_supports_deny_paths() + } + fn build_process_security_environment_spec(request: &ExecutionRequest) -> Vec { let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024); let version = SchemaVersion::new(1, 0); @@ -1058,7 +1243,7 @@ impl BaseContainerRunner { let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: Build sandbox spec"); let capture_denials = request.policy.capture_denials.clone(); - let use_process_security_environment = Self::uses_process_security_environment(&request); + let use_process_security_environment = self.uses_process_security_environment(&request); let spec_bytes = if !use_process_security_environment { let bytes = Self::build_sandbox_spec(&request); Self::log_sandbox_spec(&bytes, logger); @@ -1418,11 +1603,11 @@ impl BaseContainerRunner { let current_env_ptr = env_ptr; let current_creation_flags = creation_flags; - // Schema 0.8 and later launch through a process security environment. - // captureDenials additionally starts a learning-mode trace before child - // launch; otherwise the environment alone owns the PSEC policy. Both - // owners are RAII guards, so early returns close the environment (and - // discard an unsealed trace) without leaking broker state. + // Schema 0.8 and later prefer a process security environment when its + // runtime probe succeeds. During the SBOX-to-PSEC transition, ordinary + // requests fall back to the legacy contract when PSEC is unavailable. + // captureDenials still requires PSEC because SBOX cannot provide the + // environment handle needed to key the trace. let mut capture_session: Option> = None; let mut security_environment: Option = None; if use_process_security_environment { @@ -1666,7 +1851,12 @@ impl BaseContainerRunner { // Classify a disabled-feature error as BackendUnavailable; any // other launch error stays LaunchFailed. - let failure_phase = if is_api_not_implemented(err.0) { + let failure_phase = if is_api_not_implemented(err.0) + || is_schema_0_8_proxy_fallback_unavailable( + err.0, + &request, + use_process_security_environment, + ) { FailurePhase::BackendUnavailable } else { FailurePhase::LaunchFailed @@ -1854,12 +2044,24 @@ struct BaseChild { impl SandboxBackend for BaseContainerRunner { fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { let capture_denials = request.policy.capture_denials.is_some(); - let use_process_security_environment = Self::uses_process_security_environment(request); - if capture_denials && !use_process_security_environment { + let schema_prefers_process_security_environment = + Self::schema_prefers_process_security_environment(request); + let use_process_security_environment = self.uses_process_security_environment(request); + if capture_denials && !schema_prefers_process_security_environment { return Err(ScriptResponse::error( "processContainer.captureDenials requires schema version 0.8.0 or later", )); } + if capture_denials && !use_process_security_environment { + return Err(ScriptResponse { + failure_phase: FailurePhase::BackendUnavailable, + ..ScriptResponse::error( + "processContainer.captureDenials requires the official process \ + security-environment APIs; this host can only use a legacy \ + ProcessContainer fallback", + ) + }); + } if use_process_security_environment && request.policy.least_privilege_mode { return Err(ScriptResponse::error( "schema version 0.8.0 and later cannot be combined with \ @@ -1895,9 +2097,9 @@ impl SandboxBackend for BaseContainerRunner { )) })?; } - // deniedPaths reaches schema <=0.7 BaseContainer through SBOX and - // schema 0.8+ through PSEC. Each path has a distinct support query; - // fail closed rather than silently dropping the deny policy. + // deniedPaths reaches BaseContainer through whichever contract the + // runtime probe selected. Each path has a distinct support query; fail + // closed rather than silently dropping the deny policy. if !request.policy.denied_paths.is_empty() { let deny_supported = if use_process_security_environment { self.capture_support @@ -2770,11 +2972,53 @@ mod tests { fn is_api_not_implemented_classifies_disabled_feature() { assert!(is_api_not_implemented(ERROR_CALL_NOT_IMPLEMENTED.0)); assert!(is_api_not_implemented(E_NOTIMPL.0 as u32)); - // ERROR_INVALID_PARAMETER (87) and success are ordinary, not "disabled". + // ERROR_NOT_SUPPORTED, ERROR_INVALID_PARAMETER, and success are not + // globally classified as disabled-feature failures. + assert!(!is_api_not_implemented(ERROR_NOT_SUPPORTED.0)); assert!(!is_api_not_implemented(87)); assert!(!is_api_not_implemented(0)); } + #[test] + fn error_not_supported_is_backend_unavailable_only_for_schema_0_8_proxy_fallback() { + let mut proxy_request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + ..Default::default() + }; + proxy_request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + + assert!(is_schema_0_8_proxy_fallback_unavailable( + ERROR_NOT_SUPPORTED.0, + &proxy_request, + false + )); + assert!(!is_schema_0_8_proxy_fallback_unavailable( + ERROR_NOT_SUPPORTED.0, + &proxy_request, + true + )); + + proxy_request.schema_version = "0.7.0-alpha".to_string(); + assert!(!is_schema_0_8_proxy_fallback_unavailable( + ERROR_NOT_SUPPORTED.0, + &proxy_request, + false + )); + + let ordinary_request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + ..Default::default() + }; + assert!(!is_schema_0_8_proxy_fallback_unavailable( + ERROR_NOT_SUPPORTED.0, + &ordinary_request, + false + )); + } + #[test] fn learning_mode_api_not_implemented_checks_primary_failure() { use learning_mode_windows::LearningModeError; @@ -2885,6 +3129,53 @@ mod tests { )); } + #[test] + fn legacy_sbox_proxy_compatibility_uses_appcontainer_on_query_aware_hosts() { + let mut request = ExecutionRequest::default(); + request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + + assert!(BaseContainerRunner::legacy_sbox_compatible_with_request( + &request, None + )); + assert!(!BaseContainerRunner::legacy_sbox_compatible_with_request( + &request, + Some(SANDBOX_CAP_CREATE_PROCESS_IN_SANDBOX) + )); + assert!(!BaseContainerRunner::legacy_sbox_compatible_with_request( + &request, + Some(SANDBOX_CAP_CREATE_PROCESS_IN_SANDBOX | SANDBOX_CAP_NETWORK_PROXY) + )); + assert_eq!( + BaseContainerRunner::decode_sbox_proxy_contract(None), + SboxProxyContract::LegacyOrUnknown + ); + assert_eq!( + BaseContainerRunner::decode_sbox_proxy_contract(Some( + SANDBOX_CAP_CREATE_PROCESS_IN_SANDBOX + )), + SboxProxyContract::Unavailable + ); + assert_eq!( + BaseContainerRunner::decode_sbox_proxy_contract(Some( + SANDBOX_CAP_CREATE_PROCESS_IN_SANDBOX | SANDBOX_CAP_NETWORK_PROXY + )), + SboxProxyContract::Model2PeerIdentity + ); + } + + #[test] + fn legacy_sbox_non_proxy_requests_ignore_proxy_contract_capability() { + let request = ExecutionRequest::default(); + + assert!(BaseContainerRunner::legacy_sbox_compatible_with_request( + &request, + Some(SANDBOX_CAP_CREATE_PROCESS_IN_SANDBOX | SANDBOX_CAP_NETWORK_PROXY) + )); + } + #[test] fn build_sandbox_spec_produces_valid_flatbuffer() { let mut request = ExecutionRequest::default(); @@ -3018,7 +3309,7 @@ mod tests { } #[test] - fn process_security_environment_routing_uses_schema_version() { + fn process_security_environment_preference_uses_schema_version() { for (version, expected) in [ ("", false), ("0.6.0-alpha", false), @@ -3032,13 +3323,73 @@ mod tests { ..Default::default() }; assert_eq!( - BaseContainerRunner::uses_process_security_environment(&request), + BaseContainerRunner::schema_prefers_process_security_environment(&request), expected, "schema version {version}" ); } } + #[test] + fn schema_0_8_uses_psec_only_when_runtime_probe_succeeds() { + let request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + ..Default::default() + }; + + assert!(BaseContainerRunner::should_use_process_security_environment(&request, true, true)); + assert!( + !BaseContainerRunner::should_use_process_security_environment(&request, false, true) + ); + } + + #[test] + fn schema_0_8_proxy_uses_legacy_contract() { + let mut request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + ..Default::default() + }; + request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + + let runner = BaseContainerRunner::with_capture_factory(fake_capture_factory()); + assert!( + !BaseContainerRunner::should_use_process_security_environment(&request, true, true) + ); + assert!( + !runner.uses_process_security_environment(&request), + "schema 0.8 proxy requests must build the legacy SBOX contract" + ); + } + + #[test] + fn schema_0_8_least_privilege_uses_legacy_contract() { + let mut request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + ..Default::default() + }; + request.policy.least_privilege_mode = true; + + assert!( + !BaseContainerRunner::should_use_process_security_environment(&request, true, true) + ); + } + + #[test] + fn schema_0_8_denied_paths_use_legacy_contract_when_psec_lacks_support() { + let mut request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + ..Default::default() + }; + request.policy.denied_paths = vec![r"C:\secret".to_string()]; + + assert!( + !BaseContainerRunner::should_use_process_security_environment(&request, true, false) + ); + } + #[test] fn build_sandbox_spec_empty_policy() { // Default network policy is Block — no internetClient auto-add. @@ -3447,8 +3798,8 @@ mod tests { } #[test] - fn validate_runner_rejects_psec_with_least_privilege() { - let runner = BaseContainerRunner::new(); + fn validate_runner_allows_schema_0_8_least_privilege_via_legacy_contract() { + let runner = BaseContainerRunner::with_capture_factory(fake_capture_factory()); let mut request = ExecutionRequest { schema_version: "0.8.0-alpha".to_string(), dry_run: true, @@ -3456,16 +3807,14 @@ mod tests { }; request.policy.least_privilege_mode = true; - let error = runner + runner .validate(&request) - .expect_err("PSEC cannot represent leastPrivilege"); - - assert!(error.error_message.contains("leastPrivilege")); + .expect("leastPrivilege should route through the legacy SBOX contract"); } #[test] - fn validate_runner_rejects_psec_with_proxy() { - let runner = BaseContainerRunner::new(); + fn validate_runner_allows_schema_0_8_proxy_via_legacy_contract() { + let runner = BaseContainerRunner::with_capture_factory(fake_capture_factory()); let mut request = ExecutionRequest { schema_version: "0.8.0-alpha".to_string(), dry_run: true, @@ -3476,11 +3825,9 @@ mod tests { builtin_test_server: false, }; - let error = runner + runner .validate(&request) - .expect_err("PSEC proxy requires peer identity plumbing"); - - assert!(error.error_message.contains("network.proxy")); + .expect("network.proxy should route through the legacy SBOX contract"); } #[test] diff --git a/src/backends/appcontainer/common/src/dispatcher.rs b/src/backends/appcontainer/common/src/dispatcher.rs index 4cf41bc6f..d46ac1bd7 100644 --- a/src/backends/appcontainer/common/src/dispatcher.rs +++ b/src/backends/appcontainer/common/src/dispatcher.rs @@ -338,18 +338,23 @@ fn select_backend_with_fallback( ), DispatchError, > { - // Schema 0.8+ uses the official process-security-environment API, - // independently of the legacy SBOX tier probe/fallback chain. - if BaseContainerRunner::uses_process_security_environment(request) { - return Ok(( - SelectedBackend::BaseContainer(BaseContainerRunner::new()), - None, - IsolationTier::BaseContainer, - Vec::new(), - )); + // Keep the established tier fallback behavior for every schema version. + // For schema 0.8+, BaseContainerRunner prefers PSEC when available and + // otherwise uses the transitional SBOX contract. If neither BaseContainer + // contract is usable, detection continues to the AppContainer tiers. + let prefer_base_container = BaseContainerRunner::is_usable_for_request(request); + let supports_deny_paths = BaseContainerRunner::supports_deny_paths_for_request(request); + let decision = fallback_detector::detect_with_base_container_capabilities( + &request.policy, + prefer_base_container, + prefer_base_container, + supports_deny_paths, + )?; + if request.policy.capture_denials.is_some() && decision.tier != IsolationTier::BaseContainer { + return Err(DispatchError::CaptureDenialsUnsupported { + tier: decision.tier, + }); } - - let decision = fallback_detector::detect(&request.policy, /*prefer_bc=*/ true)?; let (backend, dacl_manager): (SelectedBackend, Option) = match decision.tier { IsolationTier::BaseContainer => { // Tier 1 delegates filesystem-policy enforcement to @@ -611,7 +616,7 @@ impl SandboxProcess for DaclGuardedProcess { #[cfg(test)] mod tests { use super::*; - use wxc_common::models::{ContainerPolicy, ExecutionRequest}; + use wxc_common::models::{ContainerPolicy, ExecutionRequest, ProxyAddress, ProxyConfig}; // `ForceTierGuard` lives in `crate::test_env` so the lock is // shared with the `fallback_detector::tests` module — otherwise // a dispatcher test and a fallback-detector test running on @@ -701,33 +706,68 @@ mod tests { } #[test] - fn capture_denials_selects_v2_backend_without_legacy_fallback() { + fn capture_denials_rejects_appcontainer_fallback() { let _g = ForceTierGuard::set("appcontainer-dacl"); let (mut policy, _tmp) = policy_with_rw_temp(); policy.capture_denials = Some(Default::default()); let req = schema_0_8_request(policy); - let dispatched = dispatch_with_fallback(&req).expect("V2 backend should be selected"); - assert!(matches!(dispatched.tier, IsolationTier::BaseContainer)); - assert!( - !dispatched.has_dacl_guard(), - "V2 capture must never apply legacy host DACLs" - ); + let result = dispatch_with_fallback(&req); + assert!(matches!( + result, + Err(DispatchError::CaptureDenialsUnsupported { + tier: IsolationTier::AppContainerDacl + }) + )); } #[test] - fn schema_0_8_without_capture_selects_psec_without_legacy_fallback() { + fn schema_0_8_without_capture_keeps_legacy_fallback() { let _g = ForceTierGuard::set("appcontainer-dacl"); let (policy, _tmp) = policy_with_rw_temp(); let req = schema_0_8_request(policy); - let dispatched = dispatch_with_fallback(&req).expect("PSEC backend should be selected"); - assert!(matches!(dispatched.tier, IsolationTier::BaseContainer)); + let dispatched = dispatch_with_fallback(&req).expect("fallback should be selected"); + assert!(matches!(dispatched.tier, IsolationTier::AppContainerDacl)); assert!( - !dispatched.has_dacl_guard(), - "schema 0.8+ must bypass the legacy fallback and DACL paths" + dispatched.has_dacl_guard(), + "schema 0.8 ordinary requests retain AppContainer + DACL fallback" ); } + + #[test] + fn schema_0_8_proxy_keeps_base_container_on_legacy_sbox_hosts() { + let _g = BcUsableGuard::set(true); + let mut policy = empty_policy(); + policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + let req = schema_0_8_request(policy); + + let (backend, dacl, tier, _warnings) = + select_backend_with_fallback(&req).expect("SBOX should remain eligible"); + assert!(matches!(tier, IsolationTier::BaseContainer)); + assert!(matches!(backend, SelectedBackend::BaseContainer(_))); + assert!(dacl.is_none()); + } + + #[test] + fn schema_0_8_proxy_uses_appcontainer_when_base_container_is_incompatible() { + let _g = BcUsableGuard::set(false); + let mut policy = empty_policy(); + policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + let req = schema_0_8_request(policy); + + let (backend, _dacl, tier, _warnings) = + select_backend_with_fallback(&req).expect("AppContainer fallback should be selected"); + assert_ne!(tier, IsolationTier::BaseContainer); + assert!(matches!(backend, SelectedBackend::AppContainer(_))); + } + #[test] fn dispatch_fallback_disabled_errors() { let _g = ForceTierGuard::set("appcontainer-dacl"); diff --git a/src/backends/appcontainer/common/src/fallback_detector.rs b/src/backends/appcontainer/common/src/fallback_detector.rs index ffc6eb24b..c3e4ab0cb 100644 --- a/src/backends/appcontainer/common/src/fallback_detector.rs +++ b/src/backends/appcontainer/common/src/fallback_detector.rs @@ -7,7 +7,8 @@ //! runtime probes, produces a [`TierDecision`]. Tiers are described in //! `docs/proposals/downlevel_support/basecontainer-fallback-plan-v2.md`: //! -//! 1. **Tier 1 — BaseContainer** (`Experimental_CreateProcessInSandbox`) +//! 1. **Tier 1 — BaseContainer** (PSEC preferred for schema 0.8+, with +//! transitional `Experimental_CreateProcessInSandbox` fallback) //! 2. **Tier 2 — AppContainer + BFS** (`bfscfg.exe`-driven filesystem policy) //! 3. **Tier 3 — AppContainer + DACL** (host-side DACL ACE augmentation) //! @@ -24,7 +25,7 @@ use wxc_common::models::ContainerPolicy; /// security strength. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IsolationTier { - /// Tier 1 — `Experimental_CreateProcessInSandbox` from `processmodel.dll`. + /// Tier 1 — a supported BaseContainer contract from `processmodel.dll`. BaseContainer, /// Tier 2 — AppContainer + `bfscfg.exe` BFS filesystem policy. AppContainerBfs, @@ -134,6 +135,23 @@ pub enum FallbackError { pub fn detect( policy: &ContainerPolicy, prefer_base_container: bool, +) -> Result { + detect_with_base_container_capabilities( + policy, + prefer_base_container, + is_base_container_usable(), + base_container_supports_deny_paths(), + ) +} + +/// Variant of [`detect`] for callers that have already selected which +/// BaseContainer contract applies to a request and probed that contract's +/// capabilities. +pub(crate) fn detect_with_base_container_capabilities( + policy: &ContainerPolicy, + prefer_base_container: bool, + base_container_usable: bool, + base_container_supports_deny_paths: bool, ) -> Result { let denied = !policy.denied_paths.is_empty(); let has_fs_policy = @@ -163,11 +181,11 @@ pub fn detect( let mut warnings: Vec = Vec::new(); // Tier 1 — BaseContainer - if prefer_base_container && is_base_container_usable() { - // Keep deny on Tier 1 only with native fs_deny support - // (SANDBOX_CAP_FS_DENY); T1 applies no host DACL, so otherwise - // fall through to a DACL-enforcing tier. - if !denied || base_container_supports_deny_paths() { + if prefer_base_container && base_container_usable { + // Keep deny on Tier 1 only with native deny-path support from the + // selected PSEC or SBOX contract. T1 applies no host DACL, so + // otherwise fall through to a DACL-enforcing tier. + if !denied || base_container_supports_deny_paths { return Ok(TierDecision { tier: IsolationTier::BaseContainer, needs_dacl_augmentation: false, @@ -176,7 +194,8 @@ pub fn detect( }); } warnings.push( - "BaseContainer usable but this OS does not advertise SANDBOX_CAP_FS_DENY; \ + "BaseContainer usable but the selected OS contract does not advertise native \ + deniedPaths support; \ deniedPaths cannot be enforced natively at Tier 1 — falling back to AppContainer \ for deniedPaths enforcement" .to_string(), @@ -785,7 +804,7 @@ mod tests { ); assert!(d.needs_dacl_augmentation); assert!( - d.warnings.iter().any(|w| w.contains("SANDBOX_CAP_FS_DENY")), + d.warnings.iter().any(|w| w.contains("deniedPaths support")), "expected the capability-absent fall-through warning, got: {:?}", d.warnings ); diff --git a/src/backends/learning_mode/windows/src/lib.rs b/src/backends/learning_mode/windows/src/lib.rs index bfcb0c4af..213cf2eec 100644 --- a/src/backends/learning_mode/windows/src/lib.rs +++ b/src/backends/learning_mode/windows/src/lib.rs @@ -71,6 +71,16 @@ pub use secenv::{ /// behind a stringified surrogate. #[derive(Debug, Clone, Error)] pub enum LearningModeError { + /// The named API-set group for an API surface is not implemented by this + /// Windows build. + #[error("API set `{api_set}` is not implemented; this OS build lacks the required {api} API")] + ApiSetUnavailable { + /// The API surface guarded by the named group. + api: &'static str, + /// The API-set contract queried with `IsApiSetImplemented`. + api_set: &'static str, + }, + /// `processmodel.dll` itself could not be loaded from System32. #[error("failed to load processmodel.dll: {0}")] DllLoad(String), diff --git a/src/backends/learning_mode/windows/src/secenv.rs b/src/backends/learning_mode/windows/src/secenv.rs index 623a5d639..3d68ac9b0 100644 --- a/src/backends/learning_mode/windows/src/secenv.rs +++ b/src/backends/learning_mode/windows/src/secenv.rs @@ -43,6 +43,7 @@ use windows::Win32::System::Threading::{ DeleteProcThreadAttributeList, InitializeProcThreadAttributeList, UpdateProcThreadAttribute, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, STARTUPINFOEXW, STARTUPINFOW, }; +use windows::Win32::System::WindowsProgramming::IsApiSetImplemented; use windows_core::{HRESULT, PCSTR, PCWSTR}; use wxc_common::string_util; @@ -50,6 +51,9 @@ use crate::LearningModeError; /// System DLL that hosts the flat process security-environment exports. const PROCESSMODEL_DLL: &str = "processmodel.dll"; +const SECURITY_ENVIRONMENT_API_SET_NAME: &str = "api-win-appmodel-processmodel~securityenvironment"; +const SECURITY_ENVIRONMENT_API_SET: &core::ffi::CStr = + c"api-win-appmodel-processmodel~securityenvironment"; /// No special behaviour when creating the security environment /// (`PROCESS_SECURITY_ENVIRONMENT_FLAGS` value `0`). @@ -334,6 +338,11 @@ impl std::fmt::Debug for SecurityEnvironmentApi { } } +fn is_security_environment_api_set_implemented() -> bool { + // SAFETY: the contract is a valid static null-terminated string. + unsafe { IsApiSetImplemented(PCSTR(SECURITY_ENVIRONMENT_API_SET.as_ptr().cast())).as_bool() } +} + impl SecurityEnvironmentApi { /// Load `processmodel.dll` and resolve the 2-phase security-environment exports. /// @@ -346,6 +355,8 @@ impl SecurityEnvironmentApi { /// [`supports_deny_paths`](Self::supports_deny_paths) result is memoized too. /// /// # Errors + /// - [`LearningModeError::ApiSetUnavailable`] if the security-environment + /// API-set named group is not implemented. /// - [`LearningModeError::DllLoad`] if `processmodel.dll` cannot be loaded. /// - [`LearningModeError::ExportMissing`] if any required export is absent. pub fn load() -> Result { @@ -355,6 +366,13 @@ impl SecurityEnvironmentApi { /// Perform the actual DLL load and export resolution, bypassing the cache. fn load_uncached() -> Result { + if !is_security_environment_api_set_implemented() { + return Err(LearningModeError::ApiSetUnavailable { + api: "process security-environment", + api_set: SECURITY_ENVIRONMENT_API_SET_NAME, + }); + } + let dll = string_util::to_wide(PROCESSMODEL_DLL); // SAFETY: `dll` is a valid null-terminated wide string that outlives the call. @@ -546,6 +564,10 @@ fn last_error() -> u32 { /// resolved. Returns an all-`None` report if the DLL itself cannot be loaded. #[must_use] pub fn probe_security_environment_exports() -> SecurityEnvironmentExportReport { + if !is_security_environment_api_set_implemented() { + return SecurityEnvironmentExportReport::default(); + } + let dll = string_util::to_wide(PROCESSMODEL_DLL); // SAFETY: `dll` is a valid null-terminated wide string that outlives the call; // `LOAD_LIBRARY_SEARCH_SYSTEM32` restricts the search to System32. @@ -658,7 +680,9 @@ mod tests { Err(e) => assert!( matches!( e, - LearningModeError::DllLoad(_) | LearningModeError::ExportMissing { .. } + LearningModeError::ApiSetUnavailable { .. } + | LearningModeError::DllLoad(_) + | LearningModeError::ExportMissing { .. } ), "unexpected error variant: {e}" ),