Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion docs/macos-support/seatbelt-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ settings live under a top-level `seatbelt` key:

| Field | Type | Default | Description |
|---|---|---|---|
| `seatbelt.profileOverride` | string | unset | Optional override of the generated TinyScheme sandbox profile. When set, the SDK-generated profile is replaced with this raw TinyScheme string verbatim — all `filesystem`/`network`/`ui` policy fields are ignored for profile generation (they are still type-checked). Use this only when the auto-generated profile is insufficient. |
| `seatbelt.profileOverride` | string | unset | **Dev-only.** Optional override of the generated TinyScheme sandbox profile. When set, the generated profile is replaced with this raw TinyScheme string verbatim. Because it bypasses the deny-default profile entirely, it is a catastrophic escape hatch: **release/shipped builds strip it at parse time (logging a `SECURITY` line) and compile the override path out**, so it is honored only in dev/debug builds. |
Comment thread
MGudgin marked this conversation as resolved.
Outdated
| `seatbelt.guiAccess` | boolean | `false` | When `true`, adds wildcard Mach service and IOKit rules so GUI applications can create windows and render via WindowServer. Requires `ui.disable: false`. Native AppKit apps (e.g. Terminal.app) work well; Electron-based apps may escape the sandbox via re-launch patterns. |
| `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. |
Expand Down
2 changes: 1 addition & 1 deletion docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ production configs and the dev schema when working on experimental features:
]
},
"seatbelt": { // macOS sandbox settings (macOS only)
"profileOverride": null, // Optional raw TinyScheme profile (escape hatch)
"profileOverride": null, // Dev-only escape hatch (stripped in release builds)
"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)
Expand Down
2 changes: 1 addition & 1 deletion schemas/dev/mxc-config.schema.0.8.0-dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@
]
},
"profileOverride": {
"description": "Replace the generated profile entirely (advanced/testing escape hatch).",
"description": "Replace the generated profile entirely (dev-only escape hatch; rejected by release builds).",
"type": [
"string",
"null"
Expand Down
2 changes: 1 addition & 1 deletion sdk/node/src/generated/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ export interface Seatbelt {
*/
nestedPty?: boolean | null;
/**
* Replace the generated profile entirely (advanced/testing escape hatch).
* Replace the generated profile entirely (dev-only escape hatch; rejected by release builds).
*/
profileOverride?: string | null;
}
Expand Down
36 changes: 36 additions & 0 deletions src/backends/seatbelt/common/src/profile_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ use wxc_common::models::{
/// Build a complete Seatbelt sandbox profile, scoping cooperative proxy
/// reachability to the resolved address supplied by the runner.
///
/// If `request.seatbelt.profile_override` is set, that string is returned
/// verbatim and policy fields are ignored. This whole-profile escape hatch is
/// **dev-only**: the branch is compiled out of release builds (and the config
/// parser strips `profileOverride` in release anyway), so a shipped binary
/// always builds the generated deny-default profile.
///
/// `pub` (not `pub(crate)`) so it stays a reachable API root: `profile_builder`
/// is compiled and unit-tested on every host, but its only in-crate caller
/// (`seatbelt_runner`) is `cfg(target_os = "macos")`, so on other targets a
Expand All @@ -43,6 +49,7 @@ pub fn build_profile_with_proxy(
request: &ExecutionRequest,
proxy_address: Option<&ProxyAddress>,
) -> Result<String, String> {
#[cfg(debug_assertions)]
if let Some(override_profile) = request
.seatbelt
.as_ref()
Expand Down Expand Up @@ -945,6 +952,7 @@ mod tests {
}

#[test]
#[cfg(debug_assertions)]
fn profile_override_takes_precedence() {
let mut r = req();
r.policy.readonly_paths = vec!["/should/be/ignored".into()];
Expand All @@ -957,6 +965,34 @@ mod tests {
assert_eq!(p, "(version 1)(allow default)");
}

#[test]
#[cfg(not(debug_assertions))]
fn profile_override_branch_is_absent_in_release() {
// Defense in depth: even if a release binary somehow reaches the builder
// with an override populated (the parser rejects it first), the override
// branch is compiled out, so the generated deny-default profile wins.
let mut r = req();
r.policy.readonly_paths = vec!["/should/be/honored".into()];
r.seatbelt = Some(SeatbeltConfig {
profile_override: Some("(version 1)(allow default)".into()),
gui_access: false,
..Default::default()
});
let p = build_profile(&r).unwrap();
assert_ne!(
p, "(version 1)(allow default)",
"release builds must not honor a caller-supplied profile"
);
assert!(
p.contains("(deny default)"),
"generated profile must keep its deny-default baseline, got: {p}"
);
assert!(
p.contains("/should/be/honored"),
"generated profile must reflect the request policy, got: {p}"
);
}

#[test]
fn paths_with_quotes_and_backslashes_are_escaped() {
let mut r = req();
Expand Down
69 changes: 64 additions & 5 deletions src/core/wxc_common/src/config_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,16 @@ fn validate_experimental_backend_keys(
}

/// Convert a typed `wire::Seatbelt` block into the validated domain struct.
fn make_seatbelt_config(sb: wire::Seatbelt) -> SeatbeltConfig {
///
/// `profileOverride` replaces the entire generated deny-default profile, so it
/// is a catastrophic escape hatch: release builds **reject** a config that sets
/// it, so a shipped binary can never run under a caller-supplied profile and
/// never silently substitutes a different one. It is honored only in dev/debug
/// builds for advanced testing.
fn make_seatbelt_config(
sb: wire::Seatbelt,
logger: &mut Logger,
) -> Result<SeatbeltConfig, WxcError> {
// Destructure (no `..`) so adding a wire field without mapping it is a
// compile error rather than a silent runtime drop.
let wire::Seatbelt {
Expand All @@ -637,14 +646,35 @@ fn make_seatbelt_config(sb: wire::Seatbelt) -> SeatbeltConfig {
keychain_access,
extra_mach_lookups,
} = sb;
SeatbeltConfig {

// SECURITY: reject the whole-profile override in release builds rather than
// dropping it. Silently substituting the generated profile would run the
// caller under a policy they did not ask for -- which can be *more*
// permissive than the custom profile they supplied. Failing closed matches
// how the reserved learning-mode capabilities are handled.
#[cfg(not(debug_assertions))]
let profile_override: Option<String> = {
if profile_override.is_some() {
let msg = "seatbelt.profileOverride is a dev-only capability and is \
Comment on lines +655 to +658
not accepted by release builds; remove it from the config"
.to_string();
logger.log_line(&format!("SECURITY: {msg}"));
return Err(WxcError::ConfigParse(msg));
}
None
};
// Touch `logger` in debug so the signature is consistent across profiles.
#[cfg(debug_assertions)]
let _ = &logger;

Ok(SeatbeltConfig {
profile_override,
gui_access: gui_access.unwrap_or(false),
launch_method: launch_method.map(Into::into).unwrap_or_default(),
nested_pty: nested_pty.unwrap_or(true),
keychain_access: keychain_access.unwrap_or(false),
extra_mach_lookups: extra_mach_lookups.unwrap_or_default(),
}
})
}

/// Resolve the optional `containment` wire enum to a concrete domain backend.
Expand Down Expand Up @@ -1543,7 +1573,10 @@ fn convert_wire_config(

// Top-level `seatbelt` config. Configs using `experimental.seatbelt` are
// rejected above.
let seatbelt = cfg.seatbelt.map(make_seatbelt_config);
let seatbelt = cfg
.seatbelt
.map(|sb| make_seatbelt_config(sb, logger))
.transpose()?;

// UI section
if let Some(raw_ui) = cfg.ui {
Expand Down Expand Up @@ -5730,7 +5763,8 @@ mod tests {
}

#[test]
fn seatbelt_profile_override_passed_through() {
#[cfg(debug_assertions)]
fn seatbelt_profile_override_passed_through_in_debug() {
let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"profileOverride": "(version 1)(deny default)"}}"#;
let encoded = base64_encode(json.as_bytes());
let mut logger = test_logger();
Expand All @@ -5743,6 +5777,31 @@ mod tests {
);
}

#[test]
#[cfg(not(debug_assertions))]
fn seatbelt_profile_override_rejected_in_release() {
let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"profileOverride": "(version 1)(allow default)"}}"#;
let encoded = base64_encode(json.as_bytes());
let mut logger = test_logger();

let err = load_request(&encoded, &mut logger, true)
.expect_err("release builds must reject profileOverride, not silently drop it");
let msg = err.to_string();
assert!(
msg.contains("seatbelt.profileOverride"),
"error must name the offending field, got: {msg}"
);
assert!(
msg.contains("dev-only") && msg.contains("not accepted"),
"error must state the dev-only rejection, got: {msg}"
);
let out = logger.get_buffer();
assert!(
out.contains("SECURITY: seatbelt.profileOverride"),
"a SECURITY line must name the field, got: {out}"
);
}

#[test]
fn seatbelt_nested_pty_defaults_to_true_when_block_present_but_field_absent() {
// seatbelt block is present but nestedPty is not specified;
Expand Down
2 changes: 1 addition & 1 deletion src/core/wxc_common/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ pub enum ClipboardPolicy {
#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Seatbelt {
/// Replace the generated profile entirely (advanced/testing escape hatch).
/// Replace the generated profile entirely (dev-only escape hatch; rejected by release builds).
pub profile_override: Option<String>,
/// Allow GUI (WindowServer) access.
pub gui_access: Option<bool>,
Expand Down
Loading