Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/copilot-instructions.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -196,6 +196,7 @@ cannot mix both formats in one request.
"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
"extraMachLookups": [] // Additional Mach service global-names the inner process may resolve
},

Expand Down
1 change: 1 addition & 0 deletions docs/seatbelt/seatbelt-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ Set under a top-level `"seatbelt"` key.
| `nestedPty` | bool | `true` | Lets the inner process allocate its own ptys. Needed by anything that spawns a shell β€” test runners, `git`, `gh`, REPLs, agent tools. Set `false` for a tighter sandbox. |
| `guiAccess` | bool | `false` | Adds Mach/IOKit rules so GUI apps can create windows. **Requires UI to be enabled**, which is spelled `ui.disable: false` (there is no `ui.enable`). |
| `keychainAccess` | bool | `false` | Opens the sandbox enough for `keytar` / Security.framework to reach the Keychain. Opt in only if genuinely needed. |
| `systemPowerAccess` | bool | `false` | Allows sleep/wake notification registration and power assertions through the RootDomain IOKit user client and power-management services. The user client also exposes other host power-management operations, so opt in only when needed. Requires schema 0.9 or later. |
| `launchMethod` | `"exec"` \| `"open"` | `"exec"` | `"exec"` applies `sandbox_init()` then execs directly. `"open"` launches Terminal.app via LaunchServices and sandboxes the inner shell β€” required only for Terminal.app. |
| `profileOverride` | string | unset | Replaces the generated profile with raw TinyScheme. **All `filesystem`/`network`/`ui` policy is ignored for profile generation.** Last resort. |
| `extraMachLookups` | string[] | `[]` | Additional Mach services the sandbox may look up, as exact `global-name` values. The escape hatch for an app that needs one XPC service without resorting to `profileOverride`. |
Expand Down
4 changes: 4 additions & 0 deletions schemas/dev/mxc-config.schema.0.9.0-alpha.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions schemas/dev/mxc-config.schema.0.9.0-dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,13 @@
"string",
"null"
]
},
"systemPowerAccess": {
"description": "Allow system sleep/wake notifications and power assertions.",
"type": [
"boolean",
"null"
]
}
},
"type": "object"
Expand Down
5 changes: 5 additions & 0 deletions sdk/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 4 additions & 0 deletions sdk/node/src/generated/v0_9_0_alpha/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions sdk/node/src/generated/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
7 changes: 7 additions & 0 deletions sdk/node/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/backends/seatbelt/common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ license.workspace = true
# the ScriptRunner trait that seatbelt_runner implements. Same dep that
# lxc_common uses.
wxc_common = { workspace = true }
semver = "1"

[target.'cfg(target_os = "macos")'.dependencies]
libc = { workspace = true }
2 changes: 1 addition & 1 deletion src/backends/seatbelt/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//! - [`profile_builder`] is platform-agnostic (pure string generation) so it
//! compiles and is fully unit-tested on every host. This lets reviewers
//! validate profile generation without a Mac.
//! - [`seatbelt_policy`] owns the backend's network invariants and effective-
//! - [`seatbelt_policy`] owns the backend's policy invariants and effective
//! policy helpers. Also platform-agnostic, so the rules are unit-tested
//! everywhere even though only macOS can execute them.
//! - [`seatbelt_runner`] is gated to `target_os = "macos"` since it spawns
Expand Down
46 changes: 46 additions & 0 deletions src/backends/seatbelt/common/src/profile_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -590,6 +591,26 @@ fn write_keychain_rules(out: &mut String, request: &ExecutionRequest) -> Result<
Ok(())
}

/// Emit the narrow Seatbelt capability needed for system power notifications

@dhoehna Darren Hoehna (dhoehna) Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is a narrow capability?

/// 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most methods have the out parameter as the last parameter. Can the out variable be moved to the end? Also, why have an out param instead of a return value?

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) {
Expand Down Expand Up @@ -1748,6 +1769,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"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can these strings ever change? If they do will this test catch that change?

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\")"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda odd that the names here have a quite around them but the previous test has no quotes. Is this delibrate?

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
Expand Down
48 changes: 48 additions & 0 deletions src/backends/seatbelt/common/src/seatbelt_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ use wxc_common::models::{
ContainerPolicy, ExecutionRequest, NetworkAction, NetworkEnforcementMode, NetworkPolicy,
};

const SYSTEM_POWER_ACCESS_VERSION_ERROR: &str =
"seatbelt.systemPowerAccess requires schema version 0.9 or later";

fn system_power_access_support(version: &str) -> Option<bool> {
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 GUI posture: `seatbelt.guiAccess` only means anything when the UI
/// policy leaves UI enabled, since every GUI grant is emitted alongside the
/// WindowServer allows. Single source of truth so the profile builder and the
Expand Down Expand Up @@ -233,6 +259,28 @@ mod tests {
}
}

#[test]
fn system_power_access_support_starts_at_v09() {
assert_eq!(system_power_access_support("0.8.0-alpha"), Some(false));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why only go down to 0.8.0?

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
Expand Down
18 changes: 18 additions & 0 deletions src/backends/seatbelt/common/src/seatbelt_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ impl SandboxBackend for SeatbeltScriptRunner {

// Seatbelt's own invariants β€” the only home for them, so a caller that
// builds an ExecutionRequest directly gets the same rules.
crate::seatbelt_policy::validate_system_power_access(request).map_err(error_response)?;
crate::seatbelt_policy::validate_seatbelt_network_policy(&request.policy)
.map_err(error_response)?;
crate::seatbelt_policy::validate_seatbelt_ui_policy(request).map_err(error_response)?;
Expand Down Expand Up @@ -855,6 +856,23 @@ mod tests {
assert!(response.error_message.contains("cannot be enforced"));
}

#[test]
fn rejects_system_power_access_before_v09() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is the same test as system_power_access_validation_rejects_pre_v09_requests?

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 a door at all for these rules: `validate` is the only
/// place they live, and `mxc_engine` will happily take an `ExecutionRequest`
/// built by hand. These assert `validate` rejects them without any help from
Expand Down
14 changes: 14 additions & 0 deletions src/core/mxc-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/core/mxc_config_contract/src/dev/stable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,9 @@ pub struct Seatbelt {
/// Whether macOS Keychain access is allowed.
#[serde(default)]
pub keychain_access: OptionalField<bool>,
/// Whether system sleep/wake notifications and power assertions are allowed.
#[serde(default)]
pub system_power_access: OptionalField<bool>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states power assertions but the variable is power_access

/// Additional Mach service global names the process may resolve.
#[serde(default)]
pub extra_mach_lookups: OptionalField<Vec<String>>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"launchMethod": "open",
"nestedPty": false,
"keychainAccess": true,
"systemPowerAccess": true,
"extraMachLookups": [
"com.apple.SecurityServer",
"com.apple.coreservices.launchservicesd"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
18 changes: 18 additions & 0 deletions src/core/mxc_config_contract/tests/v0_9_0_alpha/seatbelt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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\"", "[]", "{}"] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Copilot went a bit too in-depth with its unit testing.

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#"{
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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}"#);
}
Loading
Loading