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 reject configs that set it at parse time (logging a `SECURITY` line) and compile the override path out**, so it is honored only in dev/debug builds. |
| `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 (rejected by 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
3 changes: 2 additions & 1 deletion sdk/node/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,8 @@ export interface LxcConfig {
*/
export interface SeatbeltConfig {
/**
* Optional override of the generated TinyScheme sandbox profile.
* Dev/debug-only override of the generated TinyScheme sandbox profile.
* Release builds reject configs that set this field.
*/
profileOverride?: string;
/**
Expand Down
27 changes: 12 additions & 15 deletions sdk/node/tests/integration/macos-seatbelt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
debugSpawnOptions,
NETWORK_TEST_URL,
createTempDir,
spawnFromConfigAsync,
} from './test-helpers.js';

const seatbeltSpawnOptions = { ...debugSpawnOptions, experimental: true };
Expand Down Expand Up @@ -240,24 +241,20 @@ describe('macOS Seatbelt Container', {
);
});

it('should apply profile override from seatbelt config', { timeout: 30_000 }, async () => {
// Build a config with a custom seatbelt profile that allows everything
it('should reject profile override in release builds', { timeout: 30_000 }, async () => {
const config = sdk.createConfigFromPolicy({ version: schemaVersion });
config.process = { commandLine: "echo 'profile override works'" };
config.process = { commandLine: "echo 'profile override must not run'" };
config.seatbelt = { profileOverride: '(version 1)\n(allow default)' };
config.containerId = 'seatbelt-profile-override';

const result = await new Promise<{ exitCode: number; stdout: string }>((resolve, reject) => {
const ptyProcess = sdk.spawnSandboxFromConfig(config, seatbeltSpawnOptions);
let stdout = '';
const timer = setTimeout(() => reject(new Error('Test timed out waiting for onExit')), 25_000);
ptyProcess.onData((data: string) => { stdout += data; });
ptyProcess.onExit((event: { exitCode: number }) => {
clearTimeout(timer);
resolve({ exitCode: event.exitCode, stdout });
});
});
assert.strictEqual(result.exitCode, 0, `Expected exit 0: ${result.stdout}`);
assert.ok(result.stdout.includes('profile override works'));
const result = await spawnFromConfigAsync(config, seatbeltSpawnOptions);
assert.notStrictEqual(result.exitCode, 0, 'release builds must reject profileOverride');
assert.ok(
result.stdout.includes('seatbelt.profileOverride') &&
result.stdout.includes('dev-only') &&
result.stdout.includes('not accepted'),
`Expected profileOverride rejection, got: ${result.stdout}`,
);
assert.ok(!result.stdout.includes('profile override must not run'));
});
});
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