From c14791b1a1817cc49a3e514a3dce92a5bc748ee7 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 28 Aug 2026 17:07:21 -0300 Subject: [PATCH 1/3] feat(seatbelt): add system power access Add an opt-in capability for sleep/wake notifications and power assertions while keeping the default sandbox profile restricted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 342a9bf7-a27a-457f-aaac-c910b8cd5535 Signed-off-by: Carlos Alexandro Becker --- docs/schema.md | 3 +- docs/seatbelt/seatbelt-backend.md | 1 + .../dev/mxc-config.schema.0.9.0-alpha.json | 4 ++ schemas/dev/mxc-config.schema.0.9.0-dev.json | 7 +++ sdk/node/README.md | 5 ++ sdk/node/src/generated/v0_9_0_alpha/wire.ts | 4 ++ sdk/node/src/generated/wire.ts | 4 ++ sdk/node/src/types.ts | 7 +++ .../seatbelt/common/src/profile_builder.rs | 46 +++++++++++++++++++ src/core/mxc-sdk/README.md | 14 ++++++ .../mxc_config_contract/src/dev/stable.rs | 3 ++ .../one_shot/valid/seatbelt_complete.json | 1 + .../tests/v0_9_0_alpha/optional_fields.rs | 5 ++ .../tests/v0_9_0_alpha/seatbelt.rs | 18 ++++++++ .../tests/version_boundaries/seatbelt.rs | 7 ++- src/core/mxc_engine/src/policy.rs | 15 +++++- .../config_contract_adapters/dev/one_shot.rs | 2 + .../dev/one_shot_tests/stable_candidate.rs | 5 ++ .../src/config_contract_adapters/v0_7.rs | 1 + .../src/config_contract_adapters/v0_8.rs | 1 + src/core/wxc_common/src/config_parser.rs | 8 +++- src/core/wxc_common/src/models.rs | 7 +++ src/core/wxc_common/src/wire.rs | 2 + 23 files changed, 165 insertions(+), 5 deletions(-) diff --git a/docs/schema.md b/docs/schema.md index 9bddcefe6..967778c84 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -205,7 +205,8 @@ cannot mix both formats in one request. "guiAccess": false, // Allow GUI Mach services / IOKit / pty for window-drawing apps "launchMethod": "exec", // "exec" or "open" (LaunchServices, for Apple-constrained apps) "nestedPty": true, // Allow inner process to allocate its own pty (posix_openpt) - "keychainAccess": false // Allow Keychain via securityd / trustd / cfprefsd / lsd.* + "keychainAccess": false, // Allow Keychain via securityd / trustd / cfprefsd / lsd.* + "systemPowerAccess": false // Allow sleep/wake notifications and power assertions }, "telemetry": { // Telemetry (Windows only) "enabled": true // Emit TraceLogging ETW events via pure Rust tracelogging crate diff --git a/docs/seatbelt/seatbelt-backend.md b/docs/seatbelt/seatbelt-backend.md index fb5160541..3722cd994 100644 --- a/docs/seatbelt/seatbelt-backend.md +++ b/docs/seatbelt/seatbelt-backend.md @@ -170,6 +170,7 @@ settings live under a top-level `seatbelt` key: | `seatbelt.launchMethod` | `"exec"` \| `"open"` | `"exec"` | How to launch the sandboxed process. `"exec"` (default) uses the `sandbox_init()` API in `pre_exec` then execs the command directly — works for third-party GUI apps (Alacritty, etc.) and all CLI commands. `"open"` launches Terminal.app via LaunchServices (`open -n -W -a Terminal`) then applies the sandbox to the inner shell via the `sandbox-exec` CLI tool. This is required because Terminal.app enforces Apple Launch Constraints that kill it when exec'd by unauthorized parents. Currently only Terminal.app is supported with the `"open"` method — other Apple system apps (Calculator, TextEdit) cannot be sandboxed due to Launch Constraints and lack of an inner shell to constrain. | | `seatbelt.nestedPty` | boolean | `true` | When `true`, the inner process can allocate its own pseudo-terminals via `posix_openpt`. Required by anything that spawns a shell (test runners, `git`, `gh`, REPLs, agent tools that wrap commands in a pty). Adds `(allow pseudo-tty)` and read/write/ioctl on `/dev/ptmx` to the generated profile. Set to `false` for a tighter sandbox when the inner command does not need to allocate new ttys. | | `seatbelt.keychainAccess` | boolean | `false` | When `true`, opens the sandbox enough for `keytar` / `Security.framework` to reach the macOS Keychain end-to-end. Adds Mach lookup for `com.apple.SecurityServer`, `com.apple.securityd`, `com.apple.trustd`, `com.apple.ocspd`, `com.apple.cfprefsd.daemon`, `com.apple.xpcd`, and the `com.apple.lsd.*` family (regex); read access to `/private/var/db/mds` (Spotlight/MDS metadata) and `/private/var/protected/trustd` (trustd protected store); and read+write access to `~/Library/Keychains` (user keychain DB) and `/private/var/folders` (XPC cache and per-user containers). The system keychain stores under `/Library/Keychains` and `/System/Library/Keychains` are already covered by the baseline `/Library` and `/System` read-only allows. Off by default — opt in only when the inner workload genuinely needs Keychain access. | +| `seatbelt.systemPowerAccess` | boolean | `false` | When `true`, allows sleep/wake notification registration and power assertions through `RootDomainUserClient`, `com.apple.PowerManagement.control`, and `com.apple.iokit.powerdxpc`. Off by default because `RootDomainUserClient` also exposes other host power-management operations. Requires schema `0.9.0-alpha` or later. | ### Filesystem policy diff --git a/schemas/dev/mxc-config.schema.0.9.0-alpha.json b/schemas/dev/mxc-config.schema.0.9.0-alpha.json index 84b796cc2..c338d5157 100644 --- a/schemas/dev/mxc-config.schema.0.9.0-alpha.json +++ b/schemas/dev/mxc-config.schema.0.9.0-alpha.json @@ -1273,6 +1273,10 @@ "profileOverride": { "description": "Optional override of the generated sandbox profile.", "type": "string" + }, + "systemPowerAccess": { + "description": "Whether system sleep/wake notifications and power assertions are allowed.", + "type": "boolean" } }, "type": "object" diff --git a/schemas/dev/mxc-config.schema.0.9.0-dev.json b/schemas/dev/mxc-config.schema.0.9.0-dev.json index a3a3fa3a4..9631de5f1 100644 --- a/schemas/dev/mxc-config.schema.0.9.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.9.0-dev.json @@ -956,6 +956,13 @@ "string", "null" ] + }, + "systemPowerAccess": { + "description": "Allow system sleep/wake notifications and power assertions.", + "type": [ + "boolean", + "null" + ] } }, "type": "object" diff --git a/sdk/node/README.md b/sdk/node/README.md index 223a56a0f..99188d635 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -284,6 +284,11 @@ Backend-specific tuning lives on the returned `ContainerConfig`. The full set of Open the schema file matching your `policy.version` (e.g. `mxc-config.schema.0.6.0-alpha.json`) and look up `processContainer`, `lxc`, `experimental.wslc`, `experimental.windows_sandbox`, etc. +On macOS, set `seatbelt.systemPowerAccess: true` when a workload must prevent +sleep or receive sleep/wake notifications. This option is available with schema +`0.9.0-alpha` or later and is off by default because it opens host +power-management services. + For Windows ProcessContainer configs, `processContainer.learningMode: true` enables deny-and-record learning mode: failed accesses are logged but remain denied. The internal `learningModeLogging` and `permissiveLearningMode` diff --git a/sdk/node/src/generated/v0_9_0_alpha/wire.ts b/sdk/node/src/generated/v0_9_0_alpha/wire.ts index 842f81921..8e8dd0379 100644 --- a/sdk/node/src/generated/v0_9_0_alpha/wire.ts +++ b/sdk/node/src/generated/v0_9_0_alpha/wire.ts @@ -701,6 +701,10 @@ export interface Seatbelt { * Optional override of the generated sandbox profile. */ profileOverride?: string; + /** + * Whether system sleep/wake notifications and power assertions are allowed. + */ + systemPowerAccess?: boolean; } /** diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 811d621cc..638390ea2 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -467,6 +467,10 @@ export interface Seatbelt { * Replace the generated profile entirely (advanced/testing escape hatch). */ profileOverride?: string | null; + /** + * Allow system sleep/wake notifications and power assertions. + */ + systemPowerAccess?: boolean | null; } /** diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts index a76f6c959..b311cbf10 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -518,6 +518,13 @@ export interface SeatbeltConfig { * needs Keychain access. */ keychainAccess?: boolean; + /** + * Allow system sleep/wake notifications and power assertions. Adds + * access to the RootDomain IOKit user client and the macOS power + * management services. Requires schema `0.9.0-alpha` or later and + * defaults to `false`. + */ + systemPowerAccess?: boolean; /** * Additional Mach service global-names to allow `mach-lookup` for. * Escape hatch for callers that need a specific system service the diff --git a/src/backends/seatbelt/common/src/profile_builder.rs b/src/backends/seatbelt/common/src/profile_builder.rs index 38622173e..a61285950 100644 --- a/src/backends/seatbelt/common/src/profile_builder.rs +++ b/src/backends/seatbelt/common/src/profile_builder.rs @@ -79,6 +79,7 @@ pub fn build_profile_with_proxy( write_network_rules(&mut out, request, proxy_address); write_nested_pty_rules(&mut out, request); write_keychain_rules(&mut out, request)?; + write_system_power_rules(&mut out, request); write_extra_seatbelt_rules(&mut out, request); write_ui_rules(&mut out, request); @@ -541,6 +542,26 @@ fn write_keychain_rules(out: &mut String, request: &ExecutionRequest) -> Result< Ok(()) } +/// Emit the narrow Seatbelt capability needed for system power notifications +/// and assertions. Off by default because opening `RootDomainUserClient` also +/// exposes host power-management operations beyond notification registration. +fn write_system_power_rules(out: &mut String, request: &ExecutionRequest) { + let enabled = request + .seatbelt + .as_ref() + .is_some_and(|c| c.system_power_access); + if !enabled { + return; + } + + out.push_str(";; --- systemPowerAccess: sleep/wake notifications and assertions ---\n"); + out.push_str("(allow mach-lookup\n"); + out.push_str(" (global-name \"com.apple.PowerManagement.control\")\n"); + out.push_str(" (global-name \"com.apple.iokit.powerdxpc\"))\n"); + out.push_str("(allow iokit-open\n"); + out.push_str(" (iokit-user-client-class \"RootDomainUserClient\"))\n"); +} + /// Emit caller-provided `extraMachLookups` rules: additional Mach service /// global-names the inner process may resolve. No-op when the list is empty. fn write_extra_seatbelt_rules(out: &mut String, request: &ExecutionRequest) { @@ -1570,6 +1591,31 @@ mod tests { assert!(!p.contains("/private/var/db/mds")); } + #[test] + fn system_power_access_default_off_omits_power_services() { + let r = req(); + let p = build_profile(&r).unwrap(); + assert!(!p.contains("systemPowerAccess")); + assert!(!p.contains("com.apple.PowerManagement.control")); + assert!(!p.contains("com.apple.iokit.powerdxpc")); + assert!(!p.contains("RootDomainUserClient")); + } + + #[test] + fn system_power_access_true_allows_power_services() { + let mut r = req(); + r.seatbelt = Some(SeatbeltConfig { + system_power_access: true, + ..Default::default() + }); + let p = build_profile(&r).unwrap(); + assert!(p.contains("systemPowerAccess")); + assert!(p.contains("(global-name \"com.apple.PowerManagement.control\")")); + assert!(p.contains("(global-name \"com.apple.iokit.powerdxpc\")")); + assert!(p.contains("(iokit-user-client-class \"RootDomainUserClient\")")); + assert!(!p.contains("(allow iokit-open)\n")); + } + // Keychain rules expand `~/Library/Keychains` from $HOME at build // time, so the tests that exercise `keychain_access: true` are gated // to macOS (the only OS where this code path is actually used and diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 4a09928f6..3a2232e8f 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -388,6 +388,20 @@ default): `Containment` is `#[non_exhaustive]`, so a `match` on it needs a wildcard arm. Constructing the listed variants is unaffected. +Seatbelt system power access is off by default. Enable it when a workload must +prevent sleep or receive sleep/wake notifications: + +```rust,no_run +# use mxc_sdk::{build_request, SandboxPolicy}; +# let policy = SandboxPolicy { +# version: "0.9.0-alpha".to_string(), +# filesystem: None, network: None, ui: None, timeout_ms: None, +# }; +let mut request = build_request(&policy, None)?; +request.set_seatbelt_system_power_access(true); +# Ok::<(), mxc_sdk::Error>(()) +``` + `Containment::IsolationSession` names that backend, but no entry point taking a `Containment` serves it: `run` and `spawn_sandbox` both return [`ErrorCode::UnsupportedContainment`]. Reach it through the state-aware diff --git a/src/core/mxc_config_contract/src/dev/stable.rs b/src/core/mxc_config_contract/src/dev/stable.rs index 8f03e7db3..645d90e8a 100644 --- a/src/core/mxc_config_contract/src/dev/stable.rs +++ b/src/core/mxc_config_contract/src/dev/stable.rs @@ -348,6 +348,9 @@ pub struct Seatbelt { /// Whether macOS Keychain access is allowed. #[serde(default)] pub keychain_access: OptionalField, + /// Whether system sleep/wake notifications and power assertions are allowed. + #[serde(default)] + pub system_power_access: OptionalField, /// Additional Mach service global names the process may resolve. #[serde(default)] pub extra_mach_lookups: OptionalField>, diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/valid/seatbelt_complete.json b/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/valid/seatbelt_complete.json index 80ebe1a1c..dcd718868 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/valid/seatbelt_complete.json +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/valid/seatbelt_complete.json @@ -10,6 +10,7 @@ "launchMethod": "open", "nestedPty": false, "keychainAccess": true, + "systemPowerAccess": true, "extraMachLookups": [ "com.apple.SecurityServer", "com.apple.coreservices.launchservicesd" diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/optional_fields.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/optional_fields.rs index ca482c495..f8bf53e78 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/optional_fields.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/optional_fields.rs @@ -156,6 +156,11 @@ fn rejects_null_optional_fields() { version_and_process.as_str(), r#""seatbelt": {"keychainAccess": null}"#, ), + ( + "seatbelt.systemPowerAccess", + version_and_process.as_str(), + r#""seatbelt": {"systemPowerAccess": null}"#, + ), ( "seatbelt.extraMachLookups", version_and_process.as_str(), diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/seatbelt.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/seatbelt.rs index 3b1af4a5b..1659b6e2f 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/seatbelt.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/seatbelt.rs @@ -12,6 +12,7 @@ fn accepts_complete_seatbelt_object() { "launchMethod": "exec", "nestedPty": false, "keychainAccess": false, + "systemPowerAccess": false, "extraMachLookups": ["com.apple.securityd", "com.apple.coreservices.launchservicesd"] }, "process": {"commandLine": "echo"} @@ -165,6 +166,23 @@ fn rejects_non_boolean_keychain_access_values() { } } +#[test] +fn rejects_non_boolean_system_power_access_values() { + for system_power_access in ["0", "1", "\"string\"", "[]", "{}"] { + let json = format!( + r#"{{ + "version": "0.9.0-alpha", + "seatbelt": {{ + "systemPowerAccess": {system_power_access} + }}, + "process": {{"commandLine": "echo"}} + }}"# + ); + + assert_invalid(&json); + } +} + #[test] fn rejects_unknown_seatbelt_field() { let json = r#"{ diff --git a/src/core/mxc_config_contract/tests/version_boundaries/seatbelt.rs b/src/core/mxc_config_contract/tests/version_boundaries/seatbelt.rs index 0c2e892cf..838b63853 100644 --- a/src/core/mxc_config_contract/tests/version_boundaries/seatbelt.rs +++ b/src/core/mxc_config_contract/tests/version_boundaries/seatbelt.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::common::assert_v07_introduces; +use crate::common::{assert_v07_introduces, assert_v09_introduces}; #[test] fn seatbelt_section_is_introduced_in_v07() { @@ -22,3 +22,8 @@ fn macos_sandbox_section_alias_is_introduced_in_v07() { fn macos_sandbox_containment_value_alias_is_introduced_in_v07() { assert_v07_introduces(r#""containment": "macos_sandbox", "macos_sandbox": {}"#); } + +#[test] +fn system_power_access_is_introduced_in_v09() { + assert_v09_introduces(r#""seatbelt": {"systemPowerAccess": true}"#); +} diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 77f99f60c..03470e580 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -678,6 +678,17 @@ impl SandboxRequest { self } + /// Allow (or deny) the Seatbelt-sandboxed (macOS) child access to system + /// sleep/wake notifications and power assertions. Creates a default + /// Seatbelt config if the request carries none. + pub fn set_seatbelt_system_power_access(&mut self, allow: bool) -> &mut Self { + self.inner + .seatbelt + .get_or_insert_default() + .system_power_access = allow; + self + } + /// Enable (or disable) experimental features for this request — the /// analogue of the SDK's `SandboxSpawnOptions.experimental` and the /// executor's `--experimental` flag. @@ -1295,7 +1306,7 @@ mod tests { #[cfg(target_os = "macos")] #[test] - fn seatbelt_extra_mach_lookups_and_keychain_round_trip() { + fn seatbelt_options_round_trip() { let policy = SandboxPolicy { version: "0.7.0-alpha".to_string(), filesystem: None, @@ -1310,6 +1321,7 @@ mod tests { union.push("com.example.service".to_string()); request.set_seatbelt_extra_mach_lookups(union.clone()); request.set_seatbelt_keychain_access(true); + request.set_seatbelt_system_power_access(true); assert_eq!(request.seatbelt_extra_mach_lookups(), union.as_slice()); let cfg = request @@ -1318,6 +1330,7 @@ mod tests { .as_ref() .expect("seatbelt config on macOS"); assert!(cfg.keychain_access); + assert!(cfg.system_power_access); assert!(cfg .extra_mach_lookups .contains(&"com.example.service".to_string())); diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs index cfd71c8e2..0ccd8aa41 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs @@ -178,6 +178,7 @@ fn convert_seatbelt(value: contract::Seatbelt) -> wire::Seatbelt { launch_method, nested_pty, keychain_access, + system_power_access, extra_mach_lookups, } = value; wire::Seatbelt { @@ -186,6 +187,7 @@ fn convert_seatbelt(value: contract::Seatbelt) -> wire::Seatbelt { launch_method: launch_method.into_option().map(convert_launch_method), nested_pty: nested_pty.into_option(), keychain_access: keychain_access.into_option(), + system_power_access: system_power_access.into_option(), extra_mach_lookups: extra_mach_lookups.into_option(), } } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs index 7546a3af3..a2036c1b4 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs @@ -147,6 +147,7 @@ const SEATBELT_REQUEST_JSON: &str = r#"{ "launchMethod": "open", "nestedPty": true, "keychainAccess": true, + "systemPowerAccess": true, "extraMachLookups": ["com.example.service"] } }"#; @@ -216,6 +217,7 @@ const MACOS_SANDBOX_SECTION_ALIAS_REQUEST_JSON: &str = r#"{ "launchMethod": "open", "nestedPty": true, "keychainAccess": true, + "systemPowerAccess": true, "extraMachLookups": ["com.example.service"] } }"#; @@ -647,6 +649,7 @@ fn seatbelt_request_maps_expected_wire_fields() { )); assert_eq!(seatbelt.nested_pty, Some(true)); assert_eq!(seatbelt.keychain_access, Some(true)); + assert_eq!(seatbelt.system_power_access, Some(true)); assert_eq!( seatbelt.extra_mach_lookups.unwrap().as_slice(), &["com.example.service"] @@ -730,6 +733,7 @@ fn empty_seatbelt_section_maps_to_present_empty_wire_section() { assert!(seatbelt.launch_method.is_none()); assert!(seatbelt.nested_pty.is_none()); assert!(seatbelt.keychain_access.is_none()); + assert!(seatbelt.system_power_access.is_none()); assert!(seatbelt.extra_mach_lookups.is_none()); } @@ -975,6 +979,7 @@ fn macos_sandbox_section_alias_maps_expected_wire_fields() { )); assert_eq!(seatbelt.nested_pty, Some(true)); assert_eq!(seatbelt.keychain_access, Some(true)); + assert_eq!(seatbelt.system_power_access, Some(true)); assert_eq!( seatbelt.extra_mach_lookups.unwrap().as_slice(), &["com.example.service"] diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_7.rs b/src/core/wxc_common/src/config_contract_adapters/v0_7.rs index 787c90b1d..f5aa9a28b 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_7.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_7.rs @@ -230,6 +230,7 @@ fn convert_seatbelt(value: contract::Seatbelt) -> wire::Seatbelt { launch_method: launch_method.into_option().map(convert_launch_method), nested_pty: nested_pty.into_option(), keychain_access: keychain_access.into_option(), + system_power_access: None, extra_mach_lookups: extra_mach_lookups.into_option(), } } diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_8.rs b/src/core/wxc_common/src/config_contract_adapters/v0_8.rs index 1f2f344d6..f45e4a0f9 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_8.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_8.rs @@ -354,6 +354,7 @@ fn convert_seatbelt(value: contract::Seatbelt) -> wire::Seatbelt { launch_method: launch_method.into_option().map(convert_launch_method), nested_pty: nested_pty.into_option(), keychain_access: keychain_access.into_option(), + system_power_access: None, extra_mach_lookups: extra_mach_lookups.into_option(), } } diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index ad4059777..9708971f6 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -611,6 +611,7 @@ fn make_seatbelt_config(sb: wire::Seatbelt) -> SeatbeltConfig { launch_method, nested_pty, keychain_access, + system_power_access, extra_mach_lookups, } = sb; SeatbeltConfig { @@ -619,6 +620,7 @@ fn make_seatbelt_config(sb: wire::Seatbelt) -> SeatbeltConfig { launch_method: launch_method.map(Into::into).unwrap_or_default(), nested_pty: nested_pty.unwrap_or(true), keychain_access: keychain_access.unwrap_or(false), + system_power_access: system_power_access.unwrap_or(false), extra_mach_lookups: extra_mach_lookups.unwrap_or_default(), } } @@ -6243,11 +6245,12 @@ mod tests { let cfg = req.seatbelt.expect("seatbelt should be populated"); assert!(cfg.nested_pty); assert!(!cfg.keychain_access); + assert!(!cfg.system_power_access); } #[test] - fn seatbelt_nested_pty_and_keychain_access_pass_through() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"nestedPty": false, "keychainAccess": true}}"#; + fn seatbelt_options_pass_through() { + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"nestedPty": false, "keychainAccess": true, "systemPowerAccess": true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -6255,6 +6258,7 @@ mod tests { let cfg = req.seatbelt.expect("seatbelt should be populated"); assert!(!cfg.nested_pty); assert!(cfg.keychain_access); + assert!(cfg.system_power_access); } #[test] diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 02465f660..bf9bbd971 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -184,6 +184,12 @@ pub struct SeatbeltConfig { #[serde(rename = "keychainAccess", default)] pub keychain_access: bool, + /// Allow system sleep/wake notifications and power assertions. + /// Adds access to the RootDomain IOKit user client and the power + /// management Mach services. Defaults to `false`. + #[serde(rename = "systemPowerAccess", default)] + pub system_power_access: bool, + /// Additional Mach service global-names to allow `mach-lookup` for. /// Escape hatch for callers that need to talk to a system service /// the baseline doesn't cover (e.g. opt-in agent integrations). @@ -209,6 +215,7 @@ impl Default for SeatbeltConfig { launch_method: LaunchMethod::default(), nested_pty: true, keychain_access: false, + system_power_access: false, extra_mach_lookups: Vec::new(), } } diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index eb7cc1890..e4c801561 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -552,6 +552,8 @@ pub struct Seatbelt { pub nested_pty: Option, /// Allow Keychain access. pub keychain_access: Option, + /// Allow system sleep/wake notifications and power assertions. + pub system_power_access: Option, /// Additional Mach service global-names the inner process may resolve. pub extra_mach_lookups: Option>, } From 654b66a5e7d73b8acef5fc3985d6f556ec104c2b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Sat, 29 Aug 2026 13:39:05 -0300 Subject: [PATCH 2/3] fix(seatbelt): enforce power access version Gate systemPowerAccess at every one-shot loader and again before Seatbelt execution so post-parse SDK mutations cannot bypass schema 0.9. Move the documented Seatbelt block to its supported top-level location. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 342a9bf7-a27a-457f-aaac-c910b8cd5535 Signed-off-by: Carlos Alexandro Becker --- docs/schema.md | 17 +-- .../seatbelt/common/src/seatbelt_runner.rs | 19 ++++ src/core/mxc_engine/src/policy.rs | 5 +- src/core/wxc_common/src/config_parser.rs | 106 +++++++++++++++++- src/core/wxc_common/src/seatbelt_policy.rs | 59 +++++++++- 5 files changed, 189 insertions(+), 17 deletions(-) diff --git a/docs/schema.md b/docs/schema.md index 967778c84..47d42f1d0 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -188,6 +188,15 @@ cannot mix both formats in one request. "release": "3.19" }, + "seatbelt": { // macOS sandbox settings (macOS only) + "profileOverride": null, // Optional raw TinyScheme profile (escape hatch) + "guiAccess": false, // Allow GUI Mach services / IOKit / pty for window-drawing apps + "launchMethod": "exec", // "exec" or "open" (LaunchServices, for Apple-constrained apps) + "nestedPty": true, // Allow inner process to allocate its own pty (posix_openpt) + "keychainAccess": false, // Allow Keychain via securityd / trustd / cfprefsd / lsd.* + "systemPowerAccess": false // Allow sleep/wake notifications and power assertions + }, + "experimental": { // Experimental features (requires --experimental) "wslc": { // WSL Container settings "image": "alpine:latest", // Container image name @@ -200,14 +209,6 @@ cannot mix both formats in one request. { "windowsPort": 8080, "containerPort": 80, "protocol": "tcp" } ] }, - "seatbelt": { // macOS sandbox settings (macOS only) - "profileOverride": null, // Optional raw TinyScheme profile (escape hatch) - "guiAccess": false, // Allow GUI Mach services / IOKit / pty for window-drawing apps - "launchMethod": "exec", // "exec" or "open" (LaunchServices, for Apple-constrained apps) - "nestedPty": true, // Allow inner process to allocate its own pty (posix_openpt) - "keychainAccess": false, // Allow Keychain via securityd / trustd / cfprefsd / lsd.* - "systemPowerAccess": false // Allow sleep/wake notifications and power assertions - }, "telemetry": { // Telemetry (Windows only) "enabled": true // Emit TraceLogging ETW events via pure Rust tracelogging crate } diff --git a/src/backends/seatbelt/common/src/seatbelt_runner.rs b/src/backends/seatbelt/common/src/seatbelt_runner.rs index bfcf13cd9..0710e917e 100644 --- a/src/backends/seatbelt/common/src/seatbelt_runner.rs +++ b/src/backends/seatbelt/common/src/seatbelt_runner.rs @@ -121,6 +121,8 @@ impl SandboxBackend for SeatbeltScriptRunner { fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { validate_network_policy_support(request, self.network_policy_support())?; + wxc_common::seatbelt_policy::validate_system_power_access(request) + .map_err(error_response)?; // Shared with the config parser so a caller that builds an // ExecutionRequest directly gets the same rules. @@ -883,6 +885,23 @@ mod tests { assert!(response.error_message.contains("cannot be enforced")); } + #[test] + fn rejects_system_power_access_before_v09() { + let mut request = base_request(); + request.schema_version = "0.8.0-alpha".to_string(); + request + .seatbelt + .as_mut() + .expect("Seatbelt config") + .system_power_access = true; + + let error = SeatbeltScriptRunner::new().validate(&request).unwrap_err(); + assert!( + error.error_message.contains("schema version 0.9"), + "{error:?}" + ); + } + /// The parser is not the only door: `mxc_engine::run` takes an /// `ExecutionRequest` built by hand. These assert `validate` rejects the /// same policies the parser does, in both the legacy and directional shape. diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 03470e580..b55bce2fa 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -680,7 +680,8 @@ impl SandboxRequest { /// Allow (or deny) the Seatbelt-sandboxed (macOS) child access to system /// sleep/wake notifications and power assertions. Creates a default - /// Seatbelt config if the request carries none. + /// Seatbelt config if the request carries none. Enabling this on a request + /// older than schema 0.9 is rejected when the request executes. pub fn set_seatbelt_system_power_access(&mut self, allow: bool) -> &mut Self { self.inner .seatbelt @@ -1308,7 +1309,7 @@ mod tests { #[test] fn seatbelt_options_round_trip() { let policy = SandboxPolicy { - version: "0.7.0-alpha".to_string(), + version: "0.9.0-alpha".to_string(), filesystem: None, network: None, ui: None, diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 9708971f6..ae341f5a4 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -18,6 +18,7 @@ use crate::network_parser::{ directional_network_version_error, host_is_loopback, parse_network_policy, supports_directional_network, NetworkSections, }; +use crate::seatbelt_policy::{system_power_access_support, SYSTEM_POWER_ACCESS_VERSION_ERROR}; use crate::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; use crate::wire; use serde::{Deserialize, Deserializer}; @@ -130,7 +131,7 @@ pub fn load_request_with_options( .map_err(|error| WxcError::ConfigParse(error.to_string()))?; let raw: serde_json::Value = config_deserialize::from_str(&json_str) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; - validate_directional_network_field_versions(&raw)?; + validate_one_shot_field_versions(&raw)?; convert_wire_config(cfg, logger, true, opts.allow_missing_command, false) })(); @@ -154,7 +155,7 @@ pub fn load_request_from_value( let raw = config.clone(); let cfg: wire::MxcConfig = config_deserialize::from_value(config) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; - validate_directional_network_field_versions(&raw)?; + validate_one_shot_field_versions(&raw)?; convert_wire_config(cfg, logger, true, allow_missing_command, false) })(); @@ -247,7 +248,7 @@ fn parse_mxc_request_json( .map_err(|error| ParseError::OneShot(WxcError::ConfigParse(error.to_string())))?; let raw: serde_json::Value = config_deserialize::from_str(json_str) .map_err(|error| ParseError::OneShot(WxcError::ConfigParse(error.to_string())))?; - validate_directional_network_field_versions(&raw).map_err(ParseError::OneShot)?; + validate_one_shot_field_versions(&raw).map_err(ParseError::OneShot)?; convert_wire_config(cfg, logger, true, allow_missing_command, false) .map(MxcRequest::OneShot) .map_err(ParseError::OneShot) @@ -281,6 +282,36 @@ fn validate_directional_network_field_versions(config: &serde_json::Value) -> Re Ok(()) } +fn validate_one_shot_field_versions(config: &serde_json::Value) -> Result<(), WxcError> { + validate_directional_network_field_versions(config)?; + validate_system_power_access_field_version(config) +} + +fn validate_system_power_access_field_version(config: &serde_json::Value) -> Result<(), WxcError> { + let Some(config) = config.as_object() else { + return Ok(()); + }; + if let Some(version) = config.get("version").and_then(serde_json::Value::as_str) { + match system_power_access_support(version) { + None | Some(true) => return Ok(()), + Some(false) => {} + } + } + + let has_system_power_access = ["seatbelt", "macos_sandbox"] + .into_iter() + .filter_map(|key| config.get(key)) + .filter_map(serde_json::Value::as_object) + .any(|seatbelt| seatbelt.contains_key("systemPowerAccess")); + + if has_system_power_access { + return Err(WxcError::ConfigParse( + SYSTEM_POWER_ACCESS_VERSION_ERROR.to_string(), + )); + } + Ok(()) +} + fn log_one_shot_error(logger: &mut Logger, result: &Result) { if let Err(error) = result { log_error(logger, &error.to_string(), ErrorOutput::Primary); @@ -2346,6 +2377,22 @@ mod tests { } } + #[test] + fn load_request_from_value_rejects_system_power_access_before_v09() { + let config = serde_json::json!({ + "version": "0.8.0-alpha", + "process": {"commandLine": "echo hi"}, + "macos_sandbox": {"systemPowerAccess": true} + }); + let mut logger = test_logger(); + + let error = load_request_from_value(config, &mut logger, false).unwrap_err(); + assert!( + error.to_string().contains("schema version 0.9"), + "got: {error}" + ); + } + #[test] fn missing_process_section() { let json = r#"{"containment": "processcontainer"}"#; @@ -5751,6 +5798,26 @@ mod tests { assert!(result.is_err()); } + #[test] + fn malformed_schema_version_precedes_system_power_access_gate() { + let json = r#"{ + "version": "0.9x", + "process": {"commandLine": "echo hi"}, + "seatbelt": {"systemPowerAccess": true} + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let error = load_request(&encoded, &mut logger, true) + .unwrap_err() + .to_string(); + assert!(error.contains("Invalid schema version"), "got: {error}"); + assert!( + !error.contains("requires schema version 0.9"), + "got: {error}" + ); + } + #[test] fn malformed_schema_version_precedes_directional_field_gate() { let json = r#"{ @@ -6250,7 +6317,7 @@ mod tests { #[test] fn seatbelt_options_pass_through() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"nestedPty": false, "keychainAccess": true, "systemPowerAccess": true}}"#; + let json = r#"{"version": "0.9.0-alpha", "process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"nestedPty": false, "keychainAccess": true, "systemPowerAccess": true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -6261,6 +6328,37 @@ mod tests { assert!(cfg.system_power_access); } + #[test] + fn one_shot_loaders_reject_system_power_access_before_v09() { + for version in ["0.7.0-alpha", "0.8.0-alpha"] { + let json = format!( + r#"{{ + "version": "{version}", + "process": {{"commandLine": "echo hi"}}, + "containment": "seatbelt", + "seatbelt": {{"systemPowerAccess": true}} + }}"# + ); + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let load_error = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + load_error.to_string().contains("schema version 0.9"), + "load_request accepted {version}: {load_error}" + ); + + let parse_error = match load_mxc_request_from_json(&json, &mut logger) { + Err(ParseError::OneShot(error)) => error, + other => panic!("expected one-shot rejection for {version}, got: {other:?}"), + }; + assert!( + parse_error.to_string().contains("schema version 0.9"), + "load_mxc_request_from_json accepted {version}: {parse_error}" + ); + } + } + #[test] fn top_level_seatbelt_config_accepted() { let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"nestedPty": false, "keychainAccess": true}}"#; diff --git a/src/core/wxc_common/src/seatbelt_policy.rs b/src/core/wxc_common/src/seatbelt_policy.rs index 98ad633f7..54ba5eada 100644 --- a/src/core/wxc_common/src/seatbelt_policy.rs +++ b/src/core/wxc_common/src/seatbelt_policy.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Seatbelt network invariants, enforced by the backend's own `validate`. +//! Seatbelt invariants enforced by the backend's own `validate`. //! //! `validate` runs on every execution path -- the JSON parser and a Rust caller //! that hands `mxc_engine::run` an `ExecutionRequest` it built itself both @@ -11,7 +11,38 @@ //! `ScriptResponse`. use crate::host_is_canonical_loopback; -use crate::models::{ContainerPolicy, NetworkAction, NetworkEnforcementMode, NetworkPolicy}; +use crate::models::{ + ContainerPolicy, ExecutionRequest, NetworkAction, NetworkEnforcementMode, NetworkPolicy, +}; + +pub(crate) const SYSTEM_POWER_ACCESS_VERSION_ERROR: &str = + "seatbelt.systemPowerAccess requires schema version 0.9 or later"; + +/// Returns system-power capability support for a valid schema version. +/// +/// `None` leaves malformed-version diagnostics to the config parser. +pub(crate) fn system_power_access_support(version: &str) -> Option { + semver::Version::parse(version) + .ok() + .map(|version| version.major > 0 || version.minor >= 9) +} + +/// Reject system power access when the request predates its 0.9 contract. +/// +/// This validation runs at execution time so requests changed after parsing, +/// including through the Rust SDK setters, cannot bypass the version boundary. +pub fn validate_system_power_access(request: &ExecutionRequest) -> Result<(), String> { + let enabled = request + .seatbelt + .as_ref() + .is_some_and(|seatbelt| seatbelt.system_power_access); + + if enabled && !system_power_access_support(&request.schema_version).unwrap_or(false) { + return Err(SYSTEM_POWER_ACCESS_VERSION_ERROR.to_string()); + } + + Ok(()) +} /// Effective outbound posture, preferring the directional `network.egress` /// over the legacy `defaultPolicy` when both are present. @@ -115,7 +146,7 @@ pub fn validate_seatbelt_network_policy(policy: &ContainerPolicy) -> Result<(), #[cfg(test)] mod tests { use super::*; - use crate::models::{ProxyAddress, ProxyConfig}; + use crate::models::{ProxyAddress, ProxyConfig, SeatbeltConfig}; fn policy() -> ContainerPolicy { ContainerPolicy::default() @@ -132,6 +163,28 @@ mod tests { } } + #[test] + fn system_power_access_support_starts_at_v09() { + assert_eq!(system_power_access_support("0.8.0-alpha"), Some(false)); + assert_eq!(system_power_access_support("0.9.0-alpha"), Some(true)); + assert_eq!(system_power_access_support("invalid"), None); + } + + #[test] + fn system_power_access_validation_rejects_pre_v09_requests() { + let request = ExecutionRequest { + schema_version: "0.8.0-alpha".to_string(), + seatbelt: Some(SeatbeltConfig { + system_power_access: true, + ..Default::default() + }), + ..Default::default() + }; + + let error = validate_system_power_access(&request).unwrap_err(); + assert!(error.contains("schema version 0.9"), "got: {error}"); + } + #[test] fn rejects_proxy_with_default_allow() { // Outbound is already unrestricted under 'allow' — a proxy adds no From fc9780987c3306c45f51705ce8f4462381109758 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Sat, 29 Aug 2026 14:05:38 -0300 Subject: [PATCH 3/3] docs(schema): use version 0.9 in full example Keep the full schema example consistent with its systemPowerAccess field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 342a9bf7-a27a-457f-aaac-c910b8cd5535 Signed-off-by: Carlos Alexandro Becker --- docs/schema.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/schema.md b/docs/schema.md index 47d42f1d0..a11260221 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -84,7 +84,7 @@ cannot mix both formats in one request. ```json { - "version": "0.6.0-alpha", // Schema version (semver). Minimum supported: "0.6.0-alpha"; current stable: "0.8.0-alpha". + "version": "0.9.0-alpha", // Schema version (semver). Minimum supported: "0.6.0-alpha"; current development: "0.9.0-alpha". "containerId": "my-container", // Externally assigned container ID "containment": "processcontainer", // Backend (see table below)