Skip to content
Open
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
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. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thought: yea I guess the only usage for this would be to debug our profile generation and for testing. Then again, this is probably the only backend config with testing specific configuration. Wonder if it is worth it to remove it entirely.

| `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 @@ -91,7 +91,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 @@ -32,6 +32,12 @@ use wxc_common::models::{ClipboardPolicy, ExecutionRequest, NetworkPolicy, Proxy
/// 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 @@ -40,6 +46,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 @@ -776,6 +783,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 @@ -788,6 +796,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
Loading