From 5523f5609cbcd7a78823efe6ceb8a7cfaccd34d0 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:49:08 -0700 Subject: [PATCH 1/4] remove open launch method and replace probe with a check the sandbox function exists in the system library --- .github/workflows/Build.MacOS.Job.yml | 2 +- docs/api-reference/dotnet/v1/api.md | 3 +- docs/api-reference/node/v1/api.md | 2 +- docs/api-reference/rust/v1/api.md | 3 +- docs/backends/seatbelt/seatbelt-backend.md | 15 +- .../plans/backend-support-probe-api.md | 8 +- sdk/dotnet/README.md | 2 +- sdk/node/README.md | 2 +- sdk/node/src/v1/container.ts | 2 +- sdk/node/src/v1/platform.ts | 40 +- sdk/node/tests/unit/platform.test.ts | 33 ++ src/mxc-sdk/README.md | 2 +- .../src/backends/seatbelt/common/mod.rs | 4 +- .../seatbelt/common/profile_builder.rs | 2 +- .../seatbelt/common/seatbelt_runner.rs | 525 +----------------- .../config_contract_adapters/dev/one_shot.rs | 2 - .../dev/one_shot_tests/stable_candidate.rs | 2 - .../config_contract_adapters/v0_9/one_shot.rs | 1 - .../v0_9/one_shot_tests/stable_candidate.rs | 3 - .../config_contract_adapters/v1_0/one_shot.rs | 1 - .../v1_0/one_shot_tests/stable_candidate.rs | 2 - .../src/core/mxc_common/config_parser.rs | 2 - src/mxc-sdk/src/core/mxc_common/models.rs | 37 +- src/mxc-sdk/src/core/mxc_common/wire.rs | 13 - src/mxc-sdk/src/core/mxc_engine/platform.rs | 4 +- src/mxc-sdk/src/core/mxc_engine/probe.rs | 8 +- ...e2e_tests_e2e_seatbelt_characterization.rs | 2 +- 27 files changed, 100 insertions(+), 622 deletions(-) diff --git a/.github/workflows/Build.MacOS.Job.yml b/.github/workflows/Build.MacOS.Job.yml index 0a8e62023..e14f41ecd 100644 --- a/.github/workflows/Build.MacOS.Job.yml +++ b/.github/workflows/Build.MacOS.Job.yml @@ -48,7 +48,7 @@ jobs: # mxc-sdk includes the Seatbelt executor characterization tests. # mxc-exec-mac (built above via -p mxc_darwin) is what they drive; the - # tests skip via has_platform_exec() if it is missing. sandbox-exec needs + # tests skip via has_platform_exec() if it is missing. sandbox_init needs # no elevation, so they run in this standard macOS job. - name: Test run: | diff --git a/docs/api-reference/dotnet/v1/api.md b/docs/api-reference/dotnet/v1/api.md index 2241c4408..3d7f7af37 100644 --- a/docs/api-reference/dotnet/v1/api.md +++ b/docs/api-reference/dotnet/v1/api.md @@ -19,8 +19,7 @@ Creation takes `ContainerRequest` and operation options. Existing-container execution takes the `ContainerId` returned by provision, `ExecutionRequest`, and operation options. Create-and-run PTY support covers IsolationSession, Bubblewrap, LXC, and Seatbelt direct execution. Existing-container PTY support remains -IsolationSession-only. Seatbelt PTY rejects `guiAccess` and legacy -`launchMethod: "open"`. Terminal handles +IsolationSession-only. Seatbelt PTY rejects `guiAccess`. Terminal handles give the caller explicit input, output, resize, and process ownership; there is no separate public attached-console launch API or API accepting ["MXC request JSON"](../../../schema.md#mxc-request-json). diff --git a/docs/api-reference/node/v1/api.md b/docs/api-reference/node/v1/api.md index 2227f9c1d..c9655046a 100644 --- a/docs/api-reference/node/v1/api.md +++ b/docs/api-reference/node/v1/api.md @@ -20,7 +20,7 @@ execution takes the `ContainerId` returned by provision, `ExecutionRequest`, and operation options. PTY operations are asynchronous. Create-and-run PTY support covers IsolationSession, Bubblewrap, LXC, and Seatbelt direct execution. Existing-container PTY support remains IsolationSession-only. Seatbelt PTY -rejects `guiAccess` and legacy `launchMethod: "open"`. +rejects `guiAccess`. Terminal handles give the caller explicit input, output, resize, and process ownership; there is no separate attached-console launch API or API accepting ["MXC request JSON"](../../../schema.md#mxc-request-json). diff --git a/docs/api-reference/rust/v1/api.md b/docs/api-reference/rust/v1/api.md index 15d6d0a3b..e4924153c 100644 --- a/docs/api-reference/rust/v1/api.md +++ b/docs/api-reference/rust/v1/api.md @@ -21,8 +21,7 @@ Creation takes `ContainerRequest` and operation options. Existing-container execution takes the `ContainerId` returned by provision, `ExecutionRequest`, and operation options. Create-and-run PTY support covers IsolationSession, Bubblewrap, LXC, and Seatbelt direct execution. Existing-container PTY support remains -IsolationSession-only. Seatbelt PTY rejects `guiAccess` and legacy -`launchMethod: "open"`. A PTY gives the +IsolationSession-only. Seatbelt PTY rejects `guiAccess`. A PTY gives the caller explicit input, output, resize, and process ownership instead of attaching the workload to the host process's global console streams. diff --git a/docs/backends/seatbelt/seatbelt-backend.md b/docs/backends/seatbelt/seatbelt-backend.md index cb35eb515..718681674 100644 --- a/docs/backends/seatbelt/seatbelt-backend.md +++ b/docs/backends/seatbelt/seatbelt-backend.md @@ -78,7 +78,7 @@ remains deny rather than inheriting ingress allow. | Firewall / packet-filter enforcement mode | ❌ | Rejected — no packet-filter layer | | Proxy peer identity pinning | ❌ | Rejected — not supported | | Named containers, attach, lifecycle | ❌ | Not applicable — process-scoped | -| Caller-controlled PTY | ✅ | Direct `exec` only; incompatible with `guiAccess` and legacy `launchMethod: "open"` | +| Caller-controlled PTY | ✅ | Incompatible with `guiAccess` | The short version: **Seatbelt gives you an on/off switch for outbound network plus a loopback exception. It has no concept of "this host but not that one."** @@ -382,14 +382,6 @@ in `global-name`). Read access to `/private/var/db/mds` and baseline `/Library` and `/System` allows. - -> **`launchMethod` is retired with the pre-0.9 contracts.** A -> `0.9.0-alpha` config that sets it is rejected; drop the field and the process -> is launched with `exec`, like every other backend. On the older schemas, -> `"exec"` (the default) applies `sandbox_init()` then execs directly, while -> `"open"` runs the command as the first shell of a Terminal.app instance — and -> sandboxes that shell. Terminal itself runs unsandboxed. - ## Process environment **The host environment is never inherited.** The child always starts from a @@ -456,10 +448,7 @@ adds to or overrides that baseline. `inheritDefaultEnv` is rejected. `readonlyPaths[0]` → `/`. A `~` default is tilde-expanded the same way policy paths are. `PWD` is exported to the resolved directory. -Both launch methods apply it: `exec` sets it on the child process, while `open` -performs the `cd` and the `PWD` export inside the generated helper script, -since Terminal would otherwise start the workload in its own directory. A -relative `cwd` is resolved against the MXC process's directory on both paths. +A relative `cwd` is resolved against the MXC process's directory. **Note:** `getcwd()` only succeeds when the directory *itself* is readable under the profile. An out-of-policy `cwd` makes callers that resolve relative paths (`git`, Python's diff --git a/docs/development/plans/backend-support-probe-api.md b/docs/development/plans/backend-support-probe-api.md index 06349a18e..8ef52548a 100644 --- a/docs/development/plans/backend-support-probe-api.md +++ b/docs/development/plans/backend-support-probe-api.md @@ -52,8 +52,7 @@ pub enum BackendCapability { /// Probe the host and return only the backends it can currently run. /// An empty `Vec` means "no backend this API can currently affirm on this host" -/// (e.g. an unsupported platform, Linux with neither `bwrap` nor `lxc`, or -/// macOS without `sandbox-exec`) — it is a normal result, not an error. +/// (e.g. an unsupported platform, or Linux with neither `bwrap` nor `lxc`) — it is a normal result, not an error. pub fn available_backends() -> Vec; ``` @@ -145,7 +144,7 @@ See §7.9 for why the canonical probe stays in Rust rather than moving into the - On Windows the result contains `processcontainer` with a `tier` of one of the three known strings; `appcontainer-dacl` is the floor when nothing higher is reachable. - On non-Windows, `processcontainer` never appears. -- On macOS, `seatbelt` appears with `tier: None` when `/usr/bin/sandbox-exec` exists. +- On macOS, `seatbelt` appears with `tier: None` when `sandbox_init` resolves. - A serde snapshot pins the camelCase JSON shape (`{"backend":"…","tier":"…"}`) and verifies that `tier` is **omitted** when `None` (`#[serde(skip_serializing_if = "Option::is_none")]`), never serialized as `null`. @@ -195,6 +194,9 @@ contract of this API. Still open: it is not yet the transport for the rest of `getPlatformSupport()` on Linux, so the drift risk in step 2 stands, and Windows is untouched. + **Landed (macOS).** `getPlatformSupport()` reads Seatbelt availability from + `getAvailableBackends()` (`mxc_ffi`) and fails closed if that call throws. + --- ## 7. Appendix - Decisions & Notes diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index ab5549bcd..3eb58b8a2 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -105,7 +105,7 @@ Console.WriteLine($"exit={result.ExitCode}"); resizing support. Initial dimensions default to 24 rows by 80 columns. PTY stderr is merged into `Output`. Closing `Input` requests terminal EOF in canonical mode; raw-mode applications must use their own completion protocol. -Seatbelt rejects PTY mode with `guiAccess` or legacy `launchMethod: "open"`. +Seatbelt rejects PTY mode with `guiAccess`. Unsupported combinations are rejected before sandbox creation. ## Lifecycle API diff --git a/sdk/node/README.md b/sdk/node/README.md index 6d49b8491..7870f9133 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -105,7 +105,7 @@ try { and resizing support. Initial dimensions default to 24 rows by 80 columns. Terminal stderr is merged into `output`. Closing `input` requests terminal EOF when supported; raw-mode applications must use their own completion protocol. -Seatbelt rejects PTY mode with `guiAccess` or legacy `launchMethod: "open"`. +Seatbelt rejects PTY mode with `guiAccess`. Unsupported combinations are rejected before sandbox creation. ## Lifecycle API diff --git a/sdk/node/src/v1/container.ts b/sdk/node/src/v1/container.ts index b9d9c18da..e75037b44 100644 --- a/sdk/node/src/v1/container.ts +++ b/sdk/node/src/v1/container.ts @@ -154,7 +154,7 @@ function buildLinuxProcessConfig( /** * Builds the macOS process container (seatbelt) portion of a ContainerConfig. * - * The seatbelt backend's `sandbox-exec` reads a TinyScheme profile + * The seatbelt backend applies a TinyScheme profile * generated server-side by `seatbelt_common::profile_builder`, so the SDK * only needs to set the containment type and ensure the top-level `seatbelt` * config block exists — the policy fields on `ContainerConfig` (filesystem / diff --git a/sdk/node/src/v1/platform.ts b/sdk/node/src/v1/platform.ts index b728b6024..339e9eaee 100644 --- a/sdk/node/src/v1/platform.ts +++ b/sdk/node/src/v1/platform.ts @@ -419,17 +419,23 @@ function computeSupport(): PlatformSupport { const platform = os.platform(); const support: PlatformSupport = { isSupported: false, reason: '', availableMethods: [] }; - // Non-Windows platforms do not currently have native probes, so fields that - // depend on probe data (including uiCapabilities) stay omitted. + // Tier and UI fields come only from the Windows probe, so they stay omitted + // elsewhere. if (platform === 'darwin') { - // seatbelt is the only containment backend on macOS. - // /usr/bin/sandbox-exec ships with every release of macOS so the check - // is effectively just confirming we're on a supported OS. - if (isSeatbeltAvailable()) { + // Project the native probe rather than re-detecting Seatbelt here. + let backends: AvailableBackend[]; + try { + backends = darwinBackendsReader(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + support.reason = `Seatbelt availability could not be read from the native probe: ${message}`; + return support; + } + if (backends.some((entry) => entry.backend === 'seatbelt')) { support.isSupported = true; support.availableMethods = ['seatbelt']; } else { - support.reason = '/usr/bin/sandbox-exec not found; macOS install is incomplete'; + support.reason = 'Seatbelt is unavailable: sandbox_init could not be resolved'; } return support; } @@ -503,6 +509,13 @@ export function _setLxcAvailabilityProbe(fn: (() => boolean) | null): void { lxcAvailabilityProbe = fn ?? defaultLxcAvailabilityProbe; } +let darwinBackendsReader: () => AvailableBackend[] = getAvailableBackends; + +/** @internal Test-only: override the native backend reader used on macOS. */ +export function _setDarwinBackendsReader(fn: (() => AvailableBackend[]) | null): void { + darwinBackendsReader = fn ?? getAvailableBackends; +} + let platformDiagnosticLogger: (message: string) => void = diagLog; /** @internal Test-only: override platform-support diagnostic logging. */ @@ -900,19 +913,6 @@ export function _probeBubblewrap(): BubblewrapProbe { return { available: true }; } -/** - * Check if the macOS sandbox is available. `/usr/bin/sandbox-exec` is part - * of the macOS base install and present on every shipping version of macOS, - * so this is effectively a sanity check for a corrupted install. - */ -function isSeatbeltAvailable(): boolean { - try { - return fs.existsSync('/usr/bin/sandbox-exec'); - } catch { - return false; - } -} - /** * Get the simplified architecture name used for SDK bin directory layout. * @returns 'arm64' or 'x64' diff --git a/sdk/node/tests/unit/platform.test.ts b/sdk/node/tests/unit/platform.test.ts index b3375fa53..cc3c7f9d1 100644 --- a/sdk/node/tests/unit/platform.test.ts +++ b/sdk/node/tests/unit/platform.test.ts @@ -26,6 +26,7 @@ import { _runLinuxProbe, _linuxProbeTimeouts, _setLxcAvailabilityProbe, + _setDarwinBackendsReader, _setPlatformDiagnosticLogger, findWxcExecutable, _resetWxcExecutableCache, @@ -226,6 +227,38 @@ const allUiCapabilities = { canBlockDisplaySettingsChanges: true, }; +describe('getPlatformSupport on macOS', { skip: os.platform() !== 'darwin' }, () => { + beforeEach(() => _resetPlatformSupportCache()); + afterEach(() => { + _setDarwinBackendsReader(null); + _resetPlatformSupportCache(); + }); + + it('reports seatbelt when the native probe does', () => { + _setDarwinBackendsReader(() => [{ backend: 'seatbelt', capabilities: [], warnings: [] }]); + const support = getPlatformSupport(); + assert.strictEqual(support.isSupported, true); + assert.deepStrictEqual(support.availableMethods, ['seatbelt']); + }); + + it('is unsupported when the native probe omits seatbelt', () => { + _setDarwinBackendsReader(() => []); + const support = getPlatformSupport(); + assert.strictEqual(support.isSupported, false); + assert.deepStrictEqual(support.availableMethods, []); + assert.match(support.reason ?? '', /sandbox_init/); + }); + + it('fails closed when the native probe throws', () => { + _setDarwinBackendsReader(() => { + throw new Error('mxc_ffi not found'); + }); + const support = getPlatformSupport(); + assert.strictEqual(support.isSupported, false); + assert.match(support.reason ?? '', /mxc_ffi not found/); + }); +}); + describe('getPlatformSupport probe integration', () => { beforeEach(() => { _resetPlatformSupportCache(); diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index b5b68029e..e12b066a8 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -137,7 +137,7 @@ operation options and return no execution output. `v1::spawn_with_pty` creates a container with a caller-controlled terminal. Create-and-run PTY support is available for IsolationSession on Windows, Bubblewrap and LXC on Linux, and Seatbelt direct execution on macOS. LXC requires root. -Seatbelt rejects PTY mode with `guiAccess` or legacy `launchMethod: "open"`. +Seatbelt rejects PTY mode with `guiAccess`. `wait()` requests canonical-mode terminal EOF for untaken input; raw-mode applications must use their own completion protocol. Untaken merged output is drained and discarded without waiting indefinitely for descendants that keep diff --git a/src/mxc-sdk/src/backends/seatbelt/common/mod.rs b/src/mxc-sdk/src/backends/seatbelt/common/mod.rs index d45a5da08..f128f503d 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/mod.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/mod.rs @@ -11,8 +11,8 @@ //! everywhere even though only macOS can execute them. //! - [`default_env`] resolves the schema-0.9 default `process.env` block. //! Platform-agnostic for the same reason. -//! - [`seatbelt_runner`] is gated to `target_os = "macos"` since it spawns -//! `/usr/bin/sandbox-exec`. +//! - [`seatbelt_runner`] is gated to `target_os = "macos"` since it calls +//! `sandbox_init()`. pub mod default_env; pub mod profile_builder; diff --git a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs index eaf365f0b..b61b86cde 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. //! Pure builder that converts an [`ExecutionRequest`] into a TinyScheme sandbox -//! profile string suitable for `/usr/bin/sandbox-exec -p `. +//! profile string suitable for `sandbox_init()` or `sandbox-exec -p `. //! //! This module is platform-agnostic — it is just string generation — so it //! is unit-tested on every host (Windows / Linux / macOS) in CI. diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs index b6f1e8dca..2201f4b07 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs @@ -13,16 +13,11 @@ //! binary runs under a pty), while `Pipes` exposes stdout/stderr/stdin //! handles the caller can stream and `Pty` exposes a caller-owned terminal. //! -//! For apps that require LaunchServices (`launchMethod: "open"`), the runner -//! writes a sandbox helper script and launches the target app via `open -n -W`, -//! applying the sandbox to the command running inside the app. -//! //! Compiled only on macOS — the rest of the workspace continues to build //! on Windows / Linux unchanged. use std::ffi::{CStr, CString}; use std::fmt::Write as FmtWrite; -use std::fs; use std::os::fd::AsFd; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; @@ -31,7 +26,7 @@ use std::time::{Duration, Instant}; use crate::mxc_common::interruptible_reader::{wrap_pipe, InterruptibleReader, ReadCanceller}; use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{ExecutionRequest, LaunchMethod, ProxyAddress, ScriptResponse}; +use crate::mxc_common::models::{ExecutionRequest, ProxyAddress, ScriptResponse}; use crate::mxc_common::sandbox_process::{ boxed_closer, cancel_and_join_discard, duplicate_and_take_native_stdio, group_kill, spawn_discard, take_boxed_read, take_boxed_write, wait_with_timeout, NativeStdio, PtySize, @@ -228,6 +223,12 @@ const PROXY_INJECT_KEYS: &[&str] = &[ // macOS 15+. // --------------------------------------------------------------------------- +/// Whether `sandbox_init` resolves in this process, as the exec path needs. +pub(crate) fn sandbox_init_available() -> bool { + // SAFETY: `dlsym` only reads the NUL-terminated symbol name. + !unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"sandbox_init".as_ptr()) }.is_null() +} + #[link(name = "sandbox")] extern "C" { fn sandbox_init( @@ -310,23 +311,12 @@ impl SandboxBackend for SeatbeltScriptRunner { let profile = build_profile_with_proxy(request, proxy_address).map_err(error_response)?; log_generated_profile(&profile, logger); - // Determine launch method + GUI access from the seatbelt config. - let launch_method = request - .seatbelt - .as_ref() - .map(|s| s.launch_method.clone()) - .unwrap_or_default(); // Effective posture, not the raw flag: a `guiAccess` the UI policy // suppressed emits no GUI rules, so it must not cost the caller // streaming either. let gui_access = crate::seatbelt_common::seatbelt_policy::gui_access_effective(request); - match launch_method { - LaunchMethod::Exec => { - spawn_exec(&profile, request, gui_access, stdio, logger, proxy_address) - } - LaunchMethod::Open => spawn_open(&profile, request, stdio, logger, proxy_address), - } + spawn_exec(&profile, request, gui_access, stdio, logger, proxy_address) } } @@ -473,164 +463,6 @@ fn spawn_exec( termination_error: None, group: new_session || new_group || stdio != StdioMode::Inherit, session_id, - cleanup: Vec::new(), - })) -} - -/// LaunchServices launch path: write a sandbox helper + `.command` file and run -/// it in Terminal.app via `open -n -W`. Required for Apple system apps with -/// Launch Constraints (e.g. Terminal.app). The sandboxed shell runs inside -/// Terminal, not as our child, so there are no pipes to stream — only the -/// `open -W` waiter — and [`StdioMode::Pipes`] is rejected. -fn spawn_open( - profile: &str, - request: &ExecutionRequest, - stdio: StdioMode, - logger: &mut Logger, - proxy_address: Option<&ProxyAddress>, -) -> Result, ScriptResponse> { - if stdio != StdioMode::Inherit { - let mode = if stdio == StdioMode::Pipes { - "stream over pipes" - } else { - "expose a caller-owned PTY" - }; - return Err(error_response(format!( - "Seatbelt launchMethod 'open' launches Terminal.app and cannot {mode}" - ))); - } - - let _ = writeln!( - logger, - "Seatbelt: using LaunchServices (open) launch method" - ); - - // 1. Write the profile to a secure temp file. - let profile_path = match write_secure_temp_file("mxc_sb_profile_", profile, 0o600) { - Ok(p) => p, - Err(e) => return Err(error_response(format!("failed to write profile: {e}"))), - }; - - // 2. Resolve the working directory the way the exec path does. Terminal - // runs the helper from its own directory, so the helper must `cd` - // itself, and needs an absolute target. Fail here rather than inside a - // Terminal window: `open -W` reports its own exit status, not the - // helper's. - let resolved_cwd = resolved_working_directory_opt(request); - let cwd = match absolute_working_directory( - resolved_cwd - .as_deref() - .unwrap_or(UNRESOLVED_WORKING_DIRECTORY), - ) { - Ok(cwd) => cwd, - Err(e) => { - let _ = fs::remove_file(&profile_path); - return Err(error_response(format!( - "failed to read the current directory to anchor the relative seatbelt working directory: {e}" - ))); - } - }; - if let Some(reason) = working_directory_error(&cwd) { - let _ = fs::remove_file(&profile_path); - return Err(error_response(reason)); - } - // An unresolved cwd starts the child at `/`, which is no one's home. - let home_dir = resolved_cwd.as_ref().map(|_| cwd.clone()); - - // 3. Build environment exports for the helper script. When a proxy is - // active its HTTP_PROXY/HTTPS_PROXY vars are injected and caller-supplied - // proxy vars stripped (see `resolve_environment`). - let mut env_exports = String::new(); - for (key, value) in resolve_environment(request, proxy_address, home_dir.as_deref()) { - // Validate key is a safe shell identifier to prevent injection. - if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') - || key.is_empty() - || key.starts_with(|c: char| c.is_ascii_digit()) - { - continue; // Skip invalid env var names - } - // Shell-escape the value. - let escaped = escape_single_quoted(&value); - let _ = writeln!(env_exports, "export {key}='{escaped}'"); - } - - // 4. Create the sandbox helper script. - // This script is executed inside the terminal app. It: - // a) Enters the resolved working directory and exports `PWD` - // b) Calls sandbox-exec with the profile file to sandbox the shell - // c) Execs the user's command inside the sandbox - let helper_content = - build_helper_script(&env_exports, &cwd, &profile_path, &request.script_code); - - let helper_path = match write_secure_temp_file("mxc_sb_helper_", &helper_content, 0o700) { - Ok(p) => p, - Err(e) => { - let _ = fs::remove_file(&profile_path); - return Err(error_response(format!( - "failed to write helper script: {e}" - ))); - } - }; - - // 5. Create the .command file that Terminal will execute. - let command_content = format!("#!/bin/sh\nexec '{}'\n", helper_path); - let command_path = match write_secure_temp_file("mxc_sb_launch_", &command_content, 0o700) { - Ok(p) => { - // Rename to .command extension so Terminal recognizes it. - let new_path = format!("{p}.command"); - if let Err(e) = fs::rename(&p, &new_path) { - let _ = fs::remove_file(&p); - let _ = fs::remove_file(&profile_path); - let _ = fs::remove_file(&helper_path); - return Err(error_response(format!("failed to rename to .command: {e}"))); - } - new_path - } - Err(e) => { - let _ = fs::remove_file(&profile_path); - let _ = fs::remove_file(&helper_path); - return Err(error_response(format!( - "failed to write .command file: {e}" - ))); - } - }; - - let _ = writeln!(logger, "Seatbelt: launching via: open -n -W {command_path}"); - - // 6. Launch via `open -n -W`. - let child = match Command::new("open") - .args(["-n", "-W", "-a", "Terminal", &command_path]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - { - Ok(c) => c, - Err(e) => { - cleanup_files(&[&profile_path, &helper_path, &command_path]); - return Err(error_response(format!("failed to launch via open: {e}"))); - } - }; - - // The `open -W` process is the thing to wait on; the sandboxed shell runs - // inside Terminal. No streamable stdio; the temp files are removed once the - // handle's `wait()` (or drop) runs. - let started = Instant::now(); - Ok(Box::new(SeatbeltSandboxProcess { - child, - stdin: None, - stdout: None, - stderr: None, - stdout_canceller: None, - stderr_canceller: None, - pty: None, - timeout: timeout_from(request), - started, - timed_out: false, - termination_error: None, - group: false, - session_id: None, - cleanup: vec![profile_path, helper_path, command_path], })) } @@ -639,7 +471,7 @@ fn spawn_open( struct SeatbeltSandboxProcess { child: std::process::Child, /// Pipe ends — `Some` only for [`StdioMode::Pipes`]; `None` for inherited - /// stdio / Open mode (the streams are the binary's own, or detached). The + /// stdio, where the streams are the binary's own. The /// reads are wrapped so they can be cancelled out-of-band (see the /// `*_canceller` fields). stdin: Option, @@ -663,21 +495,6 @@ struct SeatbeltSandboxProcess { /// The child leads an owned session, so job-control descendants in other /// process groups can be terminated without touching the host session. session_id: Option, - /// Temp files to remove once the child exits (Open mode); empty otherwise. - cleanup: Vec, -} - -impl SeatbeltSandboxProcess { - /// Remove Open-mode temp files (profile / helper / `.command`) after the - /// child exits. Safe to call from both `wait()` and `drop`. - fn run_cleanup(&mut self) { - if self.cleanup.is_empty() { - return; - } - let files = std::mem::take(&mut self.cleanup); - let refs: Vec<&str> = files.iter().map(String::as_str).collect(); - cleanup_files(&refs); - } } impl SandboxProcess for SeatbeltSandboxProcess { @@ -832,7 +649,7 @@ impl SandboxProcess for SeatbeltSandboxProcess { // sandboxed descendants are terminated too. group_kill(&mut self.child) } else { - // Inherited / Open mode: a single process sharing the binary's + // Inherited stdio: a single process sharing the binary's // process group, so signal just it (a group-kill would hit the // binary itself). self.child.kill() @@ -933,7 +750,6 @@ impl SandboxProcess for SeatbeltSandboxProcess { pty.finish_native_bridge(); } cancel_and_join_discard(pty_thread, &pty_canceller); - self.run_cleanup(); result } } @@ -950,7 +766,6 @@ impl Drop for SeatbeltSandboxProcess { let _ = self.kill(); let _ = wait_with_timeout(&mut self.child, Some(DROP_REAP_TIMEOUT)); } - self.run_cleanup(); } } @@ -1112,9 +927,8 @@ const UNRESOLVED_WORKING_DIRECTORY: &str = "/"; /// Resolve the working directory for the sandboxed child, or `None` when the /// request resolves none. /// -/// Both launch paths resolve through here and hand the same value to the -/// environment, so the default `HOME` cannot name a directory the child never -/// entered — re-deriving it separately would also miss the `~` expansion. +/// The same value feeds the environment, so the default `HOME` cannot name a +/// directory the child never entered. fn resolved_working_directory_opt(request: &ExecutionRequest) -> Option { let expand = |path: &str| { crate::seatbelt_common::profile_builder::expand_tilde(path) @@ -1126,8 +940,7 @@ fn resolved_working_directory_opt(request: &ExecutionRequest) -> Option } /// Anchor a resolved working directory to the launcher's directory when it is -/// relative — `chdir` gives the exec path that anchor, the helper script (which -/// runs from Terminal's directory) has none. +/// relative. fn absolute_working_directory(path: &str) -> std::io::Result { absolute_working_directory_with(path, std::env::current_dir) } @@ -1135,7 +948,7 @@ fn absolute_working_directory(path: &str) -> std::io::Result { /// [`absolute_working_directory`] with an injectable launch directory. Only /// consulted for a relative value: `chdir` accepts an absolute path even when /// the launcher's own directory has been removed, so querying it eagerly would -/// reject a request the exec path launches fine. +/// reject a request that launches fine. fn absolute_working_directory_with( path: &str, launch_dir: impl FnOnce() -> std::io::Result, @@ -1147,64 +960,6 @@ fn absolute_working_directory_with( Ok(launch_dir()?.join(candidate).to_string_lossy().into_owned()) } -/// Returns the error message for when the resolved cwd isn't usable -fn working_directory_error(path: &str) -> Option { - match fs::metadata(path) { - Ok(meta) if !meta.is_dir() => Some(format!( - "seatbelt working directory '{path}' is not a directory" - )), - Ok(_) if !is_searchable(path) => Some(format!( - "seatbelt working directory '{path}' cannot be entered: no search (execute) permission" - )), - Ok(_) => None, - Err(e) => Some(format!( - "seatbelt working directory '{path}' cannot be used: {e}" - )), - } -} - -/// Does the calling user hold search permission on `path`? Answers for the real -/// uid — `mxc-exec` is not setuid — and cannot speak for a Terminal running as a -/// different user, so the helper keeps its own `cd` guard. -fn is_searchable(path: &str) -> bool { - let Ok(c_path) = CString::new(path) else { - // An interior NUL would be truncated by any C API it reaches. - return false; - }; - // SAFETY: `access` reads the NUL-terminated string it is given and returns - // an int; `c_path` owns that buffer and outlives the call. - unsafe { libc::access(c_path.as_ptr(), libc::X_OK) == 0 } -} - -/// Escape `value` for interpolation inside a single-quoted `/bin/sh` word: -/// close the quoted run, emit a literal `'`, then reopen it. The caller -/// supplies the surrounding quotes. -fn escape_single_quoted(value: &str) -> String { - value.replace('\'', "'\\''") -} - -/// Build the `/bin/sh` helper script Terminal runs for the `open` launch -/// method. -fn build_helper_script( - env_exports: &str, - working_directory: &str, - profile_path: &str, - script_code: &str, -) -> String { - let cwd = escape_single_quoted(working_directory); - format!( - "#!/bin/sh\n\ - # MXC Seatbelt sandbox helper — auto-generated, do not edit.\n\ - {env_exports}\ - cd '{cwd}' || {{ echo 'mxc: cannot enter working directory {cwd}' >&2; exit 1; }}\n\ - PWD='{cwd}'\n\ - export PWD\n\ - exec /usr/bin/sandbox-exec -f '{profile}' /bin/sh -c 'clear; {script}'\n", - profile = escape_single_quoted(profile_path), - script = escape_single_quoted(script_code), - ) -} - /// Populate `command`'s environment from a cleared baseline: never inherit the /// host environment (matching the bubblewrap `--clearenv` and AppContainer /// clean-block behaviour). [`resolved_env`] supplies the complete child @@ -1230,8 +985,7 @@ fn apply_clean_environment( /// ([`PROXY_ENV_KEYS`]) are dropped so sandboxed code cannot override or /// bypass the proxy, then the injected keys ([`PROXY_INJECT_KEYS`]: /// `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` and their lowercase forms) are -/// set to the proxy URL. Shared by the exec and open launch paths -/// so both enforce the proxy identically. +/// set to the proxy URL. /// /// `working_directory` is the directory the caller will start the child in, so /// the default `HOME` names it; see [`resolved_env`]. @@ -1256,59 +1010,6 @@ fn resolve_environment( pairs } -/// Write `content` to a temp file with a cryptographically random name and the -/// given permissions mode. Uses `O_CREAT | O_EXCL` (create_new) to avoid -/// symlink-following and collisions, and sets permissions immediately after -/// creation on the open file descriptor to close the TOCTOU window. -fn write_secure_temp_file( - prefix: &str, - content: &str, - mode: u32, -) -> Result { - use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - - let dir = std::env::temp_dir(); - - // Retry loop in case of (unlikely) collision with create_new. - for _ in 0..8 { - let random: u64 = { - // Use /dev/urandom for unpredictable temp names. - let mut buf = [0u8; 8]; - let mut f = fs::File::open("/dev/urandom")?; - std::io::Read::read_exact(&mut f, &mut buf)?; - u64::from_ne_bytes(buf) - }; - let path = dir.join(format!("{prefix}{random:016x}")); - - match fs::OpenOptions::new() - .write(true) - .create_new(true) // O_EXCL: fail if exists, no symlink follow - .mode(mode) // Set permissions atomically at creation - .open(&path) - { - Ok(mut file) => { - file.write_all(content.as_bytes())?; - return Ok(path.to_string_lossy().to_string()); - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(e), - } - } - - Err(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "failed to create unique temp file after 8 attempts", - )) -} - -/// Remove a list of temp files, ignoring errors. -fn cleanup_files(paths: &[&str]) { - for path in paths { - let _ = fs::remove_file(path); - } -} - #[cfg(test)] mod tests { use super::*; @@ -1340,21 +1041,6 @@ mod tests { assert!(error.error_message.contains("caller-owned PTY")); } - #[test] - fn open_launch_rejects_caller_owned_pty() { - let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); - let error = spawn_open( - "(version 1)\n(allow default)", - &base_request(), - StdioMode::Pty(PtySize::default()), - &mut logger, - None, - ) - .err() - .expect("open PTY must be rejected"); - assert!(error.error_message.contains("caller-owned PTY")); - } - #[test] fn the_default_block_survives_the_proxy_rewrite() { // The 0.9 default block must not bypass the proxy stripping in @@ -1700,96 +1386,9 @@ mod tests { ); } + /// An explicit directory wins; otherwise the first policy grant is used. #[test] - fn write_secure_temp_file_sets_permissions() { - use std::os::unix::fs::PermissionsExt; - - let path = write_secure_temp_file("mxc_test_", "hello", 0o700).unwrap(); - let meta = fs::metadata(&path).unwrap(); - // Verify permissions (mask with 0o777 to ignore setuid/sticky bits) - assert_eq!(meta.permissions().mode() & 0o777, 0o700); - // Verify content - assert_eq!(fs::read_to_string(&path).unwrap(), "hello"); - fs::remove_file(&path).unwrap(); - } - - #[test] - fn write_secure_temp_file_no_collisions() { - let mut paths = Vec::new(); - for _ in 0..10 { - let path = write_secure_temp_file("mxc_collision_test_", "data", 0o600).unwrap(); - assert!(!paths.contains(&path), "collision detected: {path}"); - paths.push(path); - } - for p in &paths { - let _ = fs::remove_file(p); - } - } - - /// The `open` helper must place the child the same way `spawn_exec` does. - /// Terminal starts the script in its own directory, so without these two - /// lines the request's working directory is silently discarded. - #[test] - fn open_helper_enters_working_directory_and_exports_pwd() { - let script = build_helper_script("", "/tmp/mxc_work", "/tmp/profile.sb", "pwd"); - assert!(script.contains("cd '/tmp/mxc_work' ||"), "{script}"); - assert!( - script.contains("PWD='/tmp/mxc_work'\nexport PWD\n"), - "{script}" - ); - assert!( - script.contains( - "exec /usr/bin/sandbox-exec -f '/tmp/profile.sb' /bin/sh -c 'clear; pwd'" - ), - "{script}" - ); - } - - /// A failed `cd` must abort. A bare `cd` would leave the shell in - /// Terminal's directory and run the workload in the wrong place. - #[test] - fn open_helper_aborts_when_the_working_directory_is_unusable() { - let script = build_helper_script("", "/tmp/gone", "/tmp/profile.sb", "pwd"); - let cd_line = script - .lines() - .find(|line| line.starts_with("cd ")) - .expect("cd line"); - assert!(cd_line.contains("exit 1"), "{cd_line}"); - assert!( - script.find("cd '/tmp/gone'").unwrap() - < script.find("exec /usr/bin/sandbox-exec").unwrap(), - "{script}" - ); - } - - /// `PWD` is assigned after the caller's exports so a request carrying its - /// own `PWD` cannot leave the variable disagreeing with the real directory. - #[test] - fn open_helper_pwd_export_wins_over_caller_environment() { - let script = build_helper_script("export PWD='/elsewhere'\n", "/tmp/real", "/p.sb", "pwd"); - assert!( - script.find("export PWD='/elsewhere'").unwrap() - < script.find("PWD='/tmp/real'").unwrap(), - "{script}" - ); - } - - /// Every interpolated value lands inside a single-quoted shell word, so a - /// quote in any of them must be escaped rather than closing the word. - #[test] - fn open_helper_escapes_single_quotes() { - let script = build_helper_script("", "/tmp/it's", "/tmp/o'brien.sb", "echo 'hi'"); - assert!(script.contains(r"cd '/tmp/it'\''s'"), "{script}"); - assert!(script.contains(r"PWD='/tmp/it'\''s'"), "{script}"); - assert!(script.contains(r"-f '/tmp/o'\''brien.sb'"), "{script}"); - assert!(script.contains(r"'clear; echo '\''hi'\'''"), "{script}"); - } - - /// The `open` path resolves the directory through the same - /// `resolved_working_directory_opt` the exec path uses, including its - /// policy-grant fallback when the request names no explicit directory. - #[test] - fn open_helper_uses_the_same_resolution_as_exec() { + fn working_directory_resolves_explicit_then_policy_grant() { let mut explicit = base_request(); explicit.working_directory = "/tmp/explicit".into(); assert_eq!( @@ -1803,14 +1402,6 @@ mod tests { resolved_working_directory_opt(&policy_fallback).as_deref(), Some("/tmp") ); - - let script = build_helper_script( - "", - &resolved_working_directory_opt(&policy_fallback).unwrap(), - "/p.sb", - "pwd", - ); - assert!(script.contains("cd '/tmp' ||"), "{script}"); } /// With nothing to resolve, the child still has to start somewhere, but @@ -1839,28 +1430,14 @@ mod tests { assert_eq!(env_value(&pairs, "HOME"), Some("/Users/someone/work")); } - /// A relative `process.cwd` means "relative to the launching process" — - /// that is what `chdir` gives the exec path. The helper script runs from - /// Terminal's directory instead, so the value must be anchored before it is - /// embedded. The two bases here stand in for the launcher and Terminal: - /// the same relative request must not be able to name both directories. + /// A relative `process.cwd` is relative to the launching process. #[test] fn relative_working_directory_is_anchored_to_the_launching_process() { let launcher = || Ok(PathBuf::from("/tmp/launcher")); - let terminal = || Ok(PathBuf::from("/Users/someone")); - - let anchored = absolute_working_directory_with("work", launcher).unwrap(); - assert_eq!(anchored, "/tmp/launcher/work"); - assert_ne!( - anchored, - absolute_working_directory_with("work", terminal).unwrap() + assert_eq!( + absolute_working_directory_with("work", launcher).unwrap(), + "/tmp/launcher/work" ); - - // Anchored means absolute, so the directory the helper runs from can no - // longer change where it lands. - let script = build_helper_script("", &anchored, "/p.sb", "pwd"); - assert!(script.contains("cd '/tmp/launcher/work' ||"), "{script}"); - assert!(script.contains("PWD='/tmp/launcher/work'"), "{script}"); } /// An absolute directory is passed through spelled exactly as written — @@ -1891,64 +1468,4 @@ mod tests { let err = absolute_working_directory_with("work", gone).unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::NotFound); } - - /// A stat check passes a directory `chdir` refuses, so test the search bit. - #[test] - fn unsearchable_working_directory_is_rejected() { - use std::os::unix::fs::PermissionsExt; - - let dir = std::env::temp_dir().join(format!("mxc_nox_{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir(&dir).unwrap(); - let path = dir.to_string_lossy().into_owned(); - - fs::set_permissions(&dir, fs::Permissions::from_mode(0o000)).unwrap(); - let stat_says_directory = Path::new(&path).is_dir(); - let reason = working_directory_error(&path); - - // Restore before asserting so a failure cannot leave the dir behind. - fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap(); - fs::remove_dir_all(&dir).unwrap(); - - assert!( - stat_says_directory, - "precondition: is_dir() sees a directory" - ); - let reason = reason.expect("an unsearchable directory must be rejected"); - assert!(reason.contains("search"), "{reason}"); - } - - /// A usable directory is accepted; a file or missing path names its reason. - #[test] - fn working_directory_error_accepts_usable_and_names_the_failure() { - let dir = std::env::temp_dir(); - assert_eq!(working_directory_error(&dir.to_string_lossy()), None); - - let file = dir.join(format!("mxc_file_{}", std::process::id())); - fs::write(&file, "x").unwrap(); - let reason = working_directory_error(&file.to_string_lossy()); - fs::remove_file(&file).unwrap(); - assert!( - reason - .as_deref() - .unwrap_or_default() - .contains("not a directory"), - "{reason:?}" - ); - - let missing = working_directory_error("/tmp/mxc_definitely_absent_2f9a"); - assert!( - missing - .as_deref() - .unwrap_or_default() - .contains("cannot be used"), - "{missing:?}" - ); - } - - /// An interior NUL can never name a usable directory. - #[test] - fn working_directory_with_interior_nul_is_not_searchable() { - assert!(!is_searchable("/tmp/\0/etc")); - } } diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs index cec89abf9..7fe0d5543 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs @@ -196,8 +196,6 @@ fn convert_seatbelt(value: contract::Seatbelt) -> wire::Seatbelt { wire::Seatbelt { profile_override: profile_override.into_option(), gui_access: gui_access.into_option(), - // Removed from the 0.9 contract: the inner process is always exec'd. - launch_method: None, nested_pty: nested_pty.into_option(), keychain_access: keychain_access.into_option(), extra_mach_lookups: extra_mach_lookups.into_option(), diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs index d6620ce1b..6861962a4 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs @@ -604,7 +604,6 @@ fn seatbelt_request_maps_expected_wire_fields() { Some("custom-profile.sb") ); assert_eq!(seatbelt.gui_access, Some(true)); - assert!(seatbelt.launch_method.is_none()); assert_eq!(seatbelt.nested_pty, Some(true)); assert_eq!(seatbelt.keychain_access, Some(true)); assert_eq!( @@ -683,7 +682,6 @@ fn empty_seatbelt_section_maps_to_present_empty_wire_section() { let seatbelt = wire.seatbelt.expect("seatbelt should be populated"); assert!(seatbelt.profile_override.is_none()); assert!(seatbelt.gui_access.is_none()); - assert!(seatbelt.launch_method.is_none()); assert!(seatbelt.nested_pty.is_none()); assert!(seatbelt.keychain_access.is_none()); assert!(seatbelt.extra_mach_lookups.is_none()); diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs index db0526694..f9465d57d 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs @@ -189,7 +189,6 @@ fn convert_seatbelt(value: contract::Seatbelt) -> wire::Seatbelt { wire::Seatbelt { profile_override: profile_override.into_option(), gui_access: gui_access.into_option(), - launch_method: None, nested_pty: nested_pty.into_option(), keychain_access: keychain_access.into_option(), extra_mach_lookups: extra_mach_lookups.into_option(), diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot_tests/stable_candidate.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot_tests/stable_candidate.rs index fddb30e19..f7d0833d6 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot_tests/stable_candidate.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot_tests/stable_candidate.rs @@ -623,7 +623,6 @@ fn seatbelt_request_maps_expected_wire_fields() { Some("custom-profile.sb") ); assert_eq!(seatbelt.gui_access, Some(true)); - assert!(seatbelt.launch_method.is_none()); assert_eq!(seatbelt.nested_pty, Some(true)); assert_eq!(seatbelt.keychain_access, Some(true)); assert_eq!( @@ -702,7 +701,6 @@ fn empty_seatbelt_section_maps_to_present_empty_wire_section() { let seatbelt = wire.seatbelt.expect("seatbelt should be populated"); assert!(seatbelt.profile_override.is_none()); assert!(seatbelt.gui_access.is_none()); - assert!(seatbelt.launch_method.is_none()); assert!(seatbelt.nested_pty.is_none()); assert!(seatbelt.keychain_access.is_none()); assert!(seatbelt.extra_mach_lookups.is_none()); @@ -928,7 +926,6 @@ fn macos_sandbox_section_alias_maps_expected_wire_fields() { Some("custom-profile.sb") ); assert_eq!(seatbelt.gui_access, Some(true)); - assert!(seatbelt.launch_method.is_none()); assert_eq!(seatbelt.nested_pty, Some(true)); assert_eq!(seatbelt.keychain_access, Some(true)); assert_eq!( diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs index 657ae809c..b6dc51cbc 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs @@ -189,7 +189,6 @@ fn convert_seatbelt(value: contract::Seatbelt) -> wire::Seatbelt { wire::Seatbelt { profile_override: profile_override.into_option(), gui_access: gui_access.into_option(), - launch_method: None, nested_pty: nested_pty.into_option(), keychain_access: keychain_access.into_option(), extra_mach_lookups: extra_mach_lookups.into_option(), diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot_tests/stable_candidate.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot_tests/stable_candidate.rs index b14e0072d..c96e30201 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot_tests/stable_candidate.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot_tests/stable_candidate.rs @@ -588,7 +588,6 @@ fn seatbelt_request_maps_expected_wire_fields() { Some("custom-profile.sb") ); assert_eq!(seatbelt.gui_access, Some(true)); - assert!(seatbelt.launch_method.is_none()); assert_eq!(seatbelt.nested_pty, Some(true)); assert_eq!(seatbelt.keychain_access, Some(true)); assert_eq!( @@ -667,7 +666,6 @@ fn empty_seatbelt_section_maps_to_present_empty_wire_section() { let seatbelt = wire.seatbelt.expect("seatbelt should be populated"); assert!(seatbelt.profile_override.is_none()); assert!(seatbelt.gui_access.is_none()); - assert!(seatbelt.launch_method.is_none()); assert!(seatbelt.nested_pty.is_none()); assert!(seatbelt.keychain_access.is_none()); assert!(seatbelt.extra_mach_lookups.is_none()); diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index be2969871..39c399072 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -1161,7 +1161,6 @@ fn make_seatbelt_config(sb: wire::Seatbelt) -> SeatbeltConfig { let wire::Seatbelt { profile_override, gui_access, - launch_method, nested_pty, keychain_access, extra_mach_lookups, @@ -1169,7 +1168,6 @@ fn make_seatbelt_config(sb: wire::Seatbelt) -> SeatbeltConfig { 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(), diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index 12f3be0f0..4460de7ce 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -41,7 +41,7 @@ pub enum ContainmentBackend { IsolationSession, /// macOS Seatbelt sandbox backend. /// Implemented on top of the OS-bundled sandbox facility (Apple's - /// internal codename for the App Sandbox / `sandbox-exec` machinery + /// internal codename for the App Sandbox / `sandbox_init` machinery /// is "Seatbelt"); selected on the wire as `"seatbelt"`. Seatbelt, /// Bubblewrap — unprivileged Linux sandboxing via user namespaces. @@ -151,19 +151,6 @@ pub struct SeatbeltConfig { #[serde(rename = "guiAccess", default)] pub gui_access: bool, - /// How to launch the sandboxed process. - /// - /// - `"exec"` (default): fork → sandbox_init() → exec. Stdio is inherited - /// when `guiAccess` is true, piped otherwise. Works for most - /// third-party GUI apps and all CLI commands. - /// - `"open"`: launch via macOS LaunchServices (`open -n -W`). Required - /// for Apple system apps (e.g. Terminal.app) that have Launch - /// Constraints preventing direct exec from third-party processes. - /// The sandbox is applied to the shell/command running *inside* the - /// launched app via the `sandbox-exec` CLI tool, not to the app itself. - #[serde(rename = "launchMethod", default)] - pub launch_method: LaunchMethod, - /// Allow the inner process to allocate its own pseudo-terminals via /// `posix_openpt`. Defaults to `true` because most agent-style /// workloads spawn shells (tests, `git`, `gh`, REPLs) that fail @@ -207,7 +194,6 @@ impl Default for SeatbeltConfig { Self { profile_override: None, gui_access: false, - launch_method: LaunchMethod::default(), nested_pty: true, keychain_access: false, extra_mach_lookups: Vec::new(), @@ -215,27 +201,6 @@ impl Default for SeatbeltConfig { } } -/// How to launch the sandboxed process in the Seatbelt backend. -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum LaunchMethod { - /// Direct fork → sandbox_init() → exec (default). - #[default] - Exec, - /// Launch via macOS LaunchServices (`open`). The sandbox is applied to - /// the command running inside the launched terminal app via sandbox-exec. - Open, -} - -impl From for LaunchMethod { - fn from(m: crate::mxc_common::wire::LaunchMethod) -> Self { - match m { - crate::mxc_common::wire::LaunchMethod::Exec => Self::Exec, - crate::mxc_common::wire::LaunchMethod::Open => Self::Open, - } - } -} - /// Configuration specific to the Windows Sandbox backend. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] diff --git a/src/mxc-sdk/src/core/mxc_common/wire.rs b/src/mxc-sdk/src/core/mxc_common/wire.rs index 01f338792..18b8e3adc 100644 --- a/src/mxc-sdk/src/core/mxc_common/wire.rs +++ b/src/mxc-sdk/src/core/mxc_common/wire.rs @@ -420,8 +420,6 @@ pub struct Seatbelt { pub profile_override: Option, /// Allow GUI (WindowServer) access. pub gui_access: Option, - /// Inner process launch method. - pub launch_method: Option, /// Attach the inner process to a nested pty (default true). pub nested_pty: Option, /// Allow Keychain access. @@ -430,17 +428,6 @@ pub struct Seatbelt { pub extra_mach_lookups: Option>, } -/// Seatbelt inner-process launch method. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum LaunchMethod { - /// sandbox_init() + exec (default). Works for third-party GUI apps. - Exec, - /// Launch via macOS LaunchServices (`open`), then apply the sandbox to the - /// inner shell via sandbox-exec. Required for apps with launch constraints. - Open, -} - /// Telemetry configuration (`telemetry`). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/src/mxc-sdk/src/core/mxc_engine/platform.rs b/src/mxc-sdk/src/core/mxc_engine/platform.rs index 0f208f8c8..d27f20883 100644 --- a/src/mxc-sdk/src/core/mxc_engine/platform.rs +++ b/src/mxc-sdk/src/core/mxc_engine/platform.rs @@ -140,7 +140,7 @@ where pub fn platform_support() -> PlatformSupport { #[cfg(target_os = "macos")] { - if std::path::Path::new("/usr/bin/sandbox-exec").exists() { + if crate::seatbelt_common::seatbelt_runner::sandbox_init_available() { PlatformSupport { is_supported: true, available_methods: vec![ContainmentBackend::Seatbelt.wire_name().to_string()], @@ -149,7 +149,7 @@ pub fn platform_support() -> PlatformSupport { } else { PlatformSupport { reason: Some( - "/usr/bin/sandbox-exec not found; macOS install is incomplete".to_string(), + "sandbox_init is unavailable; macOS install is incomplete".to_string(), ), ..Default::default() } diff --git a/src/mxc-sdk/src/core/mxc_engine/probe.rs b/src/mxc-sdk/src/core/mxc_engine/probe.rs index 446f5471b..fbbb8fcc4 100644 --- a/src/mxc-sdk/src/core/mxc_engine/probe.rs +++ b/src/mxc-sdk/src/core/mxc_engine/probe.rs @@ -144,15 +144,15 @@ pub fn to_json_pretty(backends: &[AvailableBackend]) -> Result Vec { - macos_backends_with(std::path::Path::new("/usr/bin/sandbox-exec").exists()) + macos_backends_with(crate::seatbelt_common::seatbelt_runner::sandbox_init_available()) } /// Split from [`macos_backends`] so the reporting is testable without /// depending on what the test host has installed. #[cfg(target_os = "macos")] -fn macos_backends_with(sandbox_exec_present: bool) -> Vec { +fn macos_backends_with(sandbox_init_available: bool) -> Vec { let mut backends = Vec::new(); - if sandbox_exec_present { + if sandbox_init_available { backends.push(AvailableBackend::tierless( ContainmentBackend::Seatbelt.wire_name(), )); @@ -597,7 +597,7 @@ mod tests { fn the_macos_probe_reports_what_its_detector_found() { assert_probe_matches_detectors(&[( ContainmentBackend::Seatbelt.wire_name(), - std::path::Path::new("/usr/bin/sandbox-exec").exists(), + crate::seatbelt_common::seatbelt_runner::sandbox_init_available(), )]); } diff --git a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_seatbelt_characterization.rs b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_seatbelt_characterization.rs index 62f062895..6a1bd699d 100644 --- a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_seatbelt_characterization.rs +++ b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_seatbelt_characterization.rs @@ -13,7 +13,7 @@ //! policy path. If they turn RED, the env/cwd model has drifted. //! //! They run in the existing macOS CI job (`cargo test --target -//! aarch64-apple-darwin`) with no extra infrastructure: `sandbox-exec` needs no +//! aarch64-apple-darwin`) with no extra infrastructure: `sandbox_init` needs no //! elevation. Each test skips cleanly if `mxc-exec-mac` has not been built. #![cfg(target_os = "macos")] From 22e80dd9790d8cd398d82d0a9e5bfb90908dc2b9 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:50:00 -0700 Subject: [PATCH 2/4] probe now uses RTLD_SELF --- .../tests/integration/macos-seatbelt.test.ts | 22 ++++++++++++++++++- .../seatbelt/common/seatbelt_runner.rs | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/sdk/node/tests/integration/macos-seatbelt.test.ts b/sdk/node/tests/integration/macos-seatbelt.test.ts index 50af06ab3..55807d8ab 100644 --- a/sdk/node/tests/integration/macos-seatbelt.test.ts +++ b/sdk/node/tests/integration/macos-seatbelt.test.ts @@ -3,7 +3,7 @@ import { describe, it, afterEach } from 'node:test'; import assert from 'node:assert'; -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -50,6 +50,26 @@ describe('macOS Seatbelt Container', { } }); + // A fresh process makes koffi's local-visibility load the first to map + // mxc_ffi, so the native sandbox_init lookup runs unmocked and uncached. + it('reports seatbelt from native discovery in a fresh process', () => { + const script = + "const { getPlatformSupport, getAvailableBackends } = await import('@microsoft/mxc-sdk/v1');" + + 'console.log(JSON.stringify({ support: getPlatformSupport(), backends: getAvailableBackends() }));'; + const output = execFileSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: path.dirname(new URL(import.meta.url).pathname), + encoding: 'utf-8', + timeout: 30000, + }); + const { support, backends } = JSON.parse(output) as { + support: { isSupported: boolean; availableMethods: string[]; reason?: string }; + backends: { backend: string }[]; + }; + assert.ok(support.isSupported, `seatbelt must be supported: ${output}`); + assert.deepStrictEqual(support.availableMethods, ['seatbelt']); + assert.ok(backends.some((entry) => entry.backend === 'seatbelt'), output); + }); + it('should execute hello world in seatbelt sandbox', async () => { const result = await sdk.runRequestForTest( "echo 'Hello from seatbelt'", diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs index 2201f4b07..3db2e8b93 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs @@ -226,7 +226,7 @@ const PROXY_INJECT_KEYS: &[&str] = &[ /// Whether `sandbox_init` resolves in this process, as the exec path needs. pub(crate) fn sandbox_init_available() -> bool { // SAFETY: `dlsym` only reads the NUL-terminated symbol name. - !unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"sandbox_init".as_ptr()) }.is_null() + !unsafe { libc::dlsym(libc::RTLD_SELF, c"sandbox_init".as_ptr()) }.is_null() } #[link(name = "sandbox")] From 7a2067173fdd2ac93b171094ae35316fdbfc5f29 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:01:56 -0700 Subject: [PATCH 3/4] Using correct path type --- sdk/node/tests/integration/macos-seatbelt.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/node/tests/integration/macos-seatbelt.test.ts b/sdk/node/tests/integration/macos-seatbelt.test.ts index 55807d8ab..2e1657151 100644 --- a/sdk/node/tests/integration/macos-seatbelt.test.ts +++ b/sdk/node/tests/integration/macos-seatbelt.test.ts @@ -57,7 +57,7 @@ describe('macOS Seatbelt Container', { "const { getPlatformSupport, getAvailableBackends } = await import('@microsoft/mxc-sdk/v1');" + 'console.log(JSON.stringify({ support: getPlatformSupport(), backends: getAvailableBackends() }));'; const output = execFileSync(process.execPath, ['--input-type=module', '-e', script], { - cwd: path.dirname(new URL(import.meta.url).pathname), + cwd: new URL('.', import.meta.url), encoding: 'utf-8', timeout: 30000, }); From 9ef4d977d266dab93c1b7b6dd768543d0e3f1026 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:08:44 -0700 Subject: [PATCH 4/4] update msg when seatbelt isn't found --- sdk/node/src/v1/platform.ts | 2 +- src/mxc-sdk/src/core/mxc_engine/platform.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/node/src/v1/platform.ts b/sdk/node/src/v1/platform.ts index 339e9eaee..9de62c2f0 100644 --- a/sdk/node/src/v1/platform.ts +++ b/sdk/node/src/v1/platform.ts @@ -435,7 +435,7 @@ function computeSupport(): PlatformSupport { support.isSupported = true; support.availableMethods = ['seatbelt']; } else { - support.reason = 'Seatbelt is unavailable: sandbox_init could not be resolved'; + support.reason = 'Seatbelt could not be found on this installation of macOS. Your macOS install might be incomplete. (sandbox_init not found)'; } return support; } diff --git a/src/mxc-sdk/src/core/mxc_engine/platform.rs b/src/mxc-sdk/src/core/mxc_engine/platform.rs index d27f20883..227708317 100644 --- a/src/mxc-sdk/src/core/mxc_engine/platform.rs +++ b/src/mxc-sdk/src/core/mxc_engine/platform.rs @@ -149,7 +149,7 @@ pub fn platform_support() -> PlatformSupport { } else { PlatformSupport { reason: Some( - "sandbox_init is unavailable; macOS install is incomplete".to_string(), + "Seatbelt could not be found on this installation of macOS. Your macOS install might be incomplete. (sandbox_init not found)".to_string(), ), ..Default::default() }