diff --git a/docs/macos-support/seatbelt-backend.md b/docs/macos-support/seatbelt-backend.md index 59d9f50d2..d75e63aad 100644 --- a/docs/macos-support/seatbelt-backend.md +++ b/docs/macos-support/seatbelt-backend.md @@ -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. | | `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. | diff --git a/docs/schema.md b/docs/schema.md index 6667e1098..542e73535 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -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) diff --git a/schemas/dev/mxc-config.schema.0.8.0-dev.json b/schemas/dev/mxc-config.schema.0.8.0-dev.json index e24b5d096..c599ff76b 100644 --- a/schemas/dev/mxc-config.schema.0.8.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.8.0-dev.json @@ -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" diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index df0c0a642..7dc8c97ba 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -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; } diff --git a/src/backends/seatbelt/common/src/profile_builder.rs b/src/backends/seatbelt/common/src/profile_builder.rs index a9a0e951a..aad3c9b89 100644 --- a/src/backends/seatbelt/common/src/profile_builder.rs +++ b/src/backends/seatbelt/common/src/profile_builder.rs @@ -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 @@ -40,6 +46,7 @@ pub fn build_profile_with_proxy( request: &ExecutionRequest, proxy_address: Option<&ProxyAddress>, ) -> Result { + #[cfg(debug_assertions)] if let Some(override_profile) = request .seatbelt .as_ref() @@ -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()]; @@ -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(); diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index c11003efe..a0362d8f5 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -8,10 +8,10 @@ use crate::encoding::base64_decode; use crate::error::WxcError; use crate::logger::Logger; use crate::models::{ - CaptureDenialsConfig, CaptureDenialsMode, ContainerPolicy, ContainmentBackend, - ExecutionRequest, ExperimentalConfig, IsolationSessionConfig, LifecycleConfig, LxcConfig, - NetworkEnforcementMode, NetworkPolicy, PortMapping, ProxyAddress, ProxyConfig, SeatbeltConfig, - TelemetryConfig, TestFeatureConfig, UiPolicy, WindowsSandboxConfig, WslcConfig, + CaptureDenialsConfig, CaptureDenialsMode, ClipboardPolicy, ContainerPolicy, ContainmentBackend, + ExecutionRequest, ExperimentalConfig, IsolationSessionConfig, LaunchMethod, LifecycleConfig, + LxcConfig, NetworkEnforcementMode, NetworkPolicy, PortMapping, ProxyAddress, ProxyConfig, + SeatbeltConfig, TelemetryConfig, TestFeatureConfig, UiPolicy, WindowsSandboxConfig, WslcConfig, }; use crate::mxc_error::MxcError; use crate::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; @@ -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 { // Destructure (no `..`) so adding a wire field without mapping it is a // compile error rather than a silent runtime drop. let wire::Seatbelt { @@ -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 = { + if profile_override.is_some() { + let msg = "seatbelt.profileOverride is a dev-only capability and is \ + 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. @@ -713,6 +743,143 @@ fn validate_capture_denials_output_path(path: &str, logger: &mut Logger) -> Resu } } +/// Render a caller-supplied string safely for a single diagnostic line. +/// +/// Free-form config values reach the security log verbatim, so an embedded +/// newline would let a caller forge additional `SECURITY: boundary relaxed:` +/// entries and poison the audit stream. Control characters are escaped and the +/// value is truncated so one field cannot dominate the log. +fn sanitize_log_value(value: &str) -> String { + const MAX: usize = 128; + let mut out = String::with_capacity(value.len().min(MAX) + 2); + out.push('"'); + for ch in value.chars().take(MAX) { + match ch { + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c if c.is_control() => out.push_str(&format!("\\u{{{:04x}}}", c as u32)), + c => out.push(c), + } + } + if value.chars().count() > MAX { + out.push_str("..."); + } + out.push('"'); + out +} + +/// Emit a standardized, deterministic warning for each setting that opens a +/// security boundary beyond the secure default, so relaxations are loud and +/// auditable in the diagnostic log rather than silently honored. Logging only — +/// never changes behavior. Filesystem grants and the seatbelt pty baseline are +/// out of scope (they are the request's primary purpose, not a relaxation). +fn log_boundary_relaxations( + policy: &ContainerPolicy, + seatbelt: Option<&SeatbeltConfig>, + logger: &mut Logger, +) { + const P: &str = "SECURITY: boundary relaxed:"; + + // Network + if policy.default_network_policy == NetworkPolicy::Allow { + logger.log_line(&format!( + "{P} network.defaultPolicy=allow (network open by default)" + )); + } + if policy.allow_local_network { + logger.log_line(&format!("{P} network.allowLocalNetwork=true")); + } + if !policy.allowed_hosts.is_empty() { + logger.log_line(&format!( + "{P} network.allowedHosts ({} host(s))", + policy.allowed_hosts.len() + )); + } + // blockedHosts is intentionally not reported: it only subtracts + // connectivity, so it never relaxes the boundary. + if policy.network_proxy.is_enabled() { + logger.log_line(&format!("{P} network.proxy enabled")); + } + + // UI. Clipboard, injection, windows, and the BaseProcess desktop knobs are + // inert while UI is disabled; only warn when ui.disable=false opens them. + // `ime` and capabilities stay effective regardless and are reported below. + let ui = &policy.base_process_ui; + // Clipboard is NOT gated on ui.disable: the Seatbelt profile builder emits + // the pasteboard mach-lookup grant purely from ui.clipboard, outside its + // ui.disable branch, so a UI-disabled macOS sandbox with clipboard enabled + // still has a live pasteboard channel. Reporting it unconditionally is + // redundant on Windows (where UI-disabled forces clipboard blocks) but never + // misses a real relaxation. + if policy.ui.clipboard != ClipboardPolicy::None { + logger.log_line(&format!("{P} ui.clipboard={:?}", policy.ui.clipboard)); + } + if !policy.ui.disable { + logger.log_line(&format!("{P} ui.disable=false (windows allowed)")); + if policy.ui.injection { + logger.log_line(&format!("{P} ui.injection=true")); + } + if ui.isolation != "container" { + logger.log_line(&format!( + "{P} processContainer.ui.isolation={}", + ui.isolation + )); + } + if ui.desktop_system_control { + logger.log_line(&format!( + "{P} processContainer.ui.desktopSystemControl=true" + )); + } + if ui.system_settings != "none" { + logger.log_line(&format!( + "{P} processContainer.ui.systemSettings={}", + sanitize_log_value(&ui.system_settings) + )); + } + if seatbelt.is_some_and(|sb| sb.gui_access) { + logger.log_line(&format!("{P} seatbelt.guiAccess=true")); + } + } + if ui.ime { + logger.log_line(&format!("{P} processContainer.ui.ime=true")); + } + + // ProcessContainer capabilities (effective regardless of ui.disable). + if !policy.capabilities.is_empty() { + logger.log_line(&format!( + "{P} processContainer.capabilities ({} cap(s))", + policy.capabilities.len() + )); + } + + // Seatbelt non-UI relaxations (independent of ui.disable). + if let Some(sb) = seatbelt { + if matches!(sb.launch_method, LaunchMethod::Open) { + logger.log_line(&format!( + "{P} seatbelt.launchMethod=open (sandbox applies to the inner \ + command, not the launched app)" + )); + } + if sb.profile_override.is_some() { + logger.log_line(&format!( + "{P} seatbelt.profileOverride (generated profile bypassed)" + )); + } + if sb.keychain_access { + logger.log_line(&format!("{P} seatbelt.keychainAccess=true")); + } + if !sb.extra_mach_lookups.is_empty() { + logger.log_line(&format!( + "{P} seatbelt.extraMachLookups ({} service(s))", + sb.extra_mach_lookups.len() + )); + } + } +} + // `allow_missing_command` relaxes the `require_process == true` arms so that a // CLI command-line override (provided by the driver after parsing) can stand in // for `process.commandLine`. When set, a missing or empty `commandLine` is @@ -1249,7 +1416,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 { @@ -1261,6 +1431,8 @@ fn convert_wire_config( }; } + log_boundary_relaxations(&policy, seatbelt.as_ref(), logger); + Ok(ExecutionRequest { schema_version, container_id, @@ -1502,7 +1674,6 @@ mod tests { use super::*; use crate::encoding::base64_encode; use crate::logger::Mode; - use crate::models::ClipboardPolicy; fn test_logger() -> Logger { Logger::new(Mode::Buffer) @@ -1770,6 +1941,111 @@ mod tests { } } + #[test] + fn boundary_relaxation_logged_for_network_and_ui() { + // A config that opens network + UI boundaries logs loud SECURITY lines; + // a secure-default config must not. + let relaxed = r#"{"process": {"commandLine": "echo hi"}, "network": {"defaultPolicy": "allow"}, "ui": {"disable": false, "injection": true}}"#; + let mut logger = test_logger(); + load_request(&base64_encode(relaxed.as_bytes()), &mut logger, true).unwrap(); + let out = logger.get_buffer(); + assert!( + out.contains("SECURITY: boundary relaxed: network.defaultPolicy=allow"), + "got: {out}" + ); + assert!(out.contains("ui.disable=false")); + assert!(out.contains("ui.injection=true")); + + let secure = r#"{"process": {"commandLine": "echo hi"}}"#; + let mut logger2 = test_logger(); + load_request(&base64_encode(secure.as_bytes()), &mut logger2, true).unwrap(); + assert!( + !logger2.get_buffer().contains("boundary relaxed"), + "secure defaults should not warn" + ); + + // blockedHosts under deny-by-default is inert and must NOT warn. + // Injection is inert while ui.disable=true (Seatbelt only omits an + // explicit HID deny, which deny-default already covers), so it is + // suppressed too. + let inert = r#"{"process": {"commandLine": "echo hi"}, "network": {"blockedHosts": ["evil.test"]}, "ui": {"injection": true}}"#; + let mut logger3 = test_logger(); + load_request(&base64_encode(inert.as_bytes()), &mut logger3, true).unwrap(); + assert!( + !logger3.get_buffer().contains("boundary relaxed"), + "inert blockedHosts + UI-disabled injection should not warn, got: {}", + logger3.get_buffer() + ); + } + + #[test] + fn clipboard_warns_even_when_ui_is_disabled() { + // The Seatbelt profile builder emits the pasteboard mach-lookup grant + // from ui.clipboard alone, outside its ui.disable branch, so clipboard + // is a live channel on macOS even with UI disabled. The warning must not + // be suppressed by ui.disable. + let json = r#"{"process": {"commandLine": "echo hi"}, "ui": {"clipboard": "all"}}"#; + let mut logger = test_logger(); + let req = load_request(&base64_encode(json.as_bytes()), &mut logger, true).unwrap(); + assert!(req.policy.ui.disable, "ui.disable defaults to true"); + let out = logger.get_buffer(); + assert!( + out.contains("SECURITY: boundary relaxed: ui.clipboard=All"), + "clipboard must warn while ui.disable=true, got: {out}" + ); + } + + #[test] + fn seatbelt_launch_method_open_warns() { + // launchMethod=open sandboxes the inner command rather than the launched + // app, which moves the trust boundary and must be reported. + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"launchMethod": "open"}}"#; + let mut logger = test_logger(); + load_request(&base64_encode(json.as_bytes()), &mut logger, true).unwrap(); + let out = logger.get_buffer(); + assert!( + out.contains("SECURITY: boundary relaxed: seatbelt.launchMethod=open"), + "got: {out}" + ); + } + + #[test] + fn mixed_policy_suppresses_only_the_inert_field() { + // Suppression must be field-specific: an inert setting stays silent while + // an effective relaxation in the same config still warns. + let json = r#"{"process": {"commandLine": "echo hi"}, "network": {"blockedHosts": ["evil.test"], "allowLocalNetwork": true}}"#; + let mut logger = test_logger(); + load_request(&base64_encode(json.as_bytes()), &mut logger, true).unwrap(); + let out = logger.get_buffer(); + assert!( + out.contains("SECURITY: boundary relaxed: network.allowLocalNetwork=true"), + "effective relaxation must still warn, got: {out}" + ); + assert!( + !out.contains("blockedHosts"), + "blockedHosts must stay silent, got: {out}" + ); + } + + #[test] + fn free_form_log_values_cannot_forge_audit_lines() { + // A newline in a free-form value must not be able to synthesize an extra + // `SECURITY: boundary relaxed:` line in the audit stream. + let forged = sanitize_log_value("custom\nSECURITY: boundary relaxed: forged=true"); + assert!( + !forged.contains('\n'), + "newline must be escaped, got: {forged}" + ); + assert!(forged.contains("\\n"), "got: {forged}"); + // Long values are truncated so one field cannot dominate the log. + let long = sanitize_log_value(&"a".repeat(500)); + assert!( + long.len() < 200, + "value should be truncated, len={}", + long.len() + ); + } + #[test] fn state_aware_exec_request_requires_command_line() { let json = r#"{ @@ -4952,7 +5228,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(); @@ -4965,6 +5242,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; diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index cdce4b0d8..f86621802 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -401,7 +401,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, /// Allow GUI (WindowServer) access. pub gui_access: Option,