Thanks to visit codestin.com
Credit goes to github.com

Skip to content

macOS: native subprocess sandbox silently disabled on macOS 15+ (Darwin >= 24) #855

Description

@joshuajbouw

Summary

SandboxCommand::wrap disables the OS-level subprocess sandbox on macOS 15+ (Darwin major version >= 24) and returns the command completely unsandboxed:

// core/crates/astrid-workspace/src/sandbox/mod.rs:115-120
if seatbelt::darwin_major_version() >= 24 {
    tracing::warn!("macOS 15+ detected: sandbox-exec is deprecated. Running host process unsandboxed.");
    return Ok(inner_cmd);
}

Darwin 24 is macOS 15 (Sequoia) and Darwin 25 is macOS 26, so this disables the sandbox on every current Mac. The stated premise, that sandbox-exec is deprecated and therefore unusable, is incorrect: sandbox-exec still enforces on current macOS. The original SIGABRT that motivated this guard was a defect in our own Seatbelt profile, not a macOS incompatibility.

Impact

SandboxCommand::wrap is the macOS arm reached from prepare_sandboxed_command (core/crates/astrid-capsule/src/engine/wasm/host/process/managed.rs:118), the host path for the host_process capability. On Darwin >= 24 every native subprocess a capsule spawns runs with no OS-level sandbox: it inherits the host user's full filesystem reach (it can read ~/.ssh, dotfiles, and write anywhere the user can). The only signal is a tracing::warn!. This contradicts the containment guarantee documented in the crate (mod.rs:43-46) and in the README.

It is also inconsistent. The MCP server spawn path calls ProcessSandboxConfig::sandbox_prefix() (core/crates/astrid-mcp/src/server.rs:567-569) with no version guard, so it does sandbox on the same macOS 15+ machine. Containment currently depends on which spawn path a capsule happens to use.

Root cause (empirically isolated)

The crash that introduced the guard is a fail-closed profile defect, not an OS incompatibility. The inline profile built in wrap() (mod.rs:131-156) is (deny default) with a narrow file-read* subpath allowlist that omits (allow file-read* (literal "/")). A spawned binary such as node stats the filesystem root at startup, the profile denies reading /, and Seatbelt correctly aborts the process with SIGABRT. The guard converted that correct fail-closed behavior into a silent fail-open passthrough.

Isolated on Darwin 25.2 by toggling individual SBPL rules under an otherwise identical deny-default profile running node -e 'process.stdout.write("ran")':

Profile Result
(allow default) exit 0 (sandbox-exec enforces fine on macOS 26)
inline wrap() shape: narrow reads, no mach*, no literal "/" exit 134 (SIGABRT, the original crash reproduced)
+ (allow mach*) only exit 134 (still SIGABRT)
+ (allow file-read* (literal "/")) only exit 0 (fixed)
+ both exit 0

The load-bearing fix is the (literal "/") read rule, not (allow mach*). Adding mach* alone still aborts.

This is already handled elsewhere in the codebase. build_seatbelt_prefix (core/crates/astrid-workspace/src/sandbox/seatbelt.rs) carries both (allow mach*) and (allow file-read* (literal "/")) (added in #534 for Node.js compatibility) and runs sandbox-exec unguarded via the MCP path today. The wrap() inline profile is a stale duplicate that never received that fix; it got the version-gate disable instead.

History

The guard was introduced in PR #603 (commit c321868), a multi-fix batch that listed this change as skipping sandbox-exec on macOS 15+ (Darwin >= 24) because it "crashes with SIGABRT", with no reproducer or analysis. It referenced #602, which is unrelated (a cwd:// VFS resolver issue), and it merged without human review.

Fix

  1. Remove the >= 24 guard (mod.rs:115-120).
  2. Route the macOS arm of wrap() through build_seatbelt_prefix, the profile that already carries (literal "/") and (allow mach*) and runs on macOS 15+ via the MCP path. Delete the stale duplicate inline profile (mod.rs:131-156) so there is a single Seatbelt profile rather than two divergent ones.
  3. Drop the now-dead darwin_major_version helper (seatbelt.rs) and its passthrough test branch.
  4. Optional hardening: add a cached seatbelt_available() runtime probe mirroring bwrap_available() (it already runs bwrap --unshare-user ... and caches the result). For Seatbelt, probe with sandbox-exec -p '(version 1)(allow default)' /usr/bin/true. On genuine failure, route through SandboxPolicy so it fails closed (Required returns an error) instead of passing through unsandboxed. This replaces a version cutoff with evidence and reuses the policy machinery from Sandbox-unavailable fallback in #651/#648 silently weakens subprocess-tier isolation — contradicts README's "Two sandboxes" promise #655. A version check cannot distinguish "sandbox-exec genuinely broken" from "our profile is malformed", which is exactly the trap here.

The minimal one-line change is to add (allow file-read* (literal "/")) to the inline profile, but consolidating onto build_seatbelt_prefix removes the duplication that caused the divergence in the first place.

Acceptance criteria

  • SandboxCommand::wrap returns a sandboxed command on Darwin >= 24 (no unconditional passthrough).
  • A single Seatbelt profile backs both the wrap() and the MCP spawn paths.
  • A test spawns a real node subprocess under the generated profile via sandbox-exec and asserts exit 0 on macOS, with a regression contrast confirming that the same profile without (literal "/") aborts (the T1 versus T3 cases above).
  • When the sandbox genuinely cannot be applied, behavior follows SandboxPolicy (fails closed under Required), never a silent unsandboxed launch.

Notes

This is kernel-internal sandbox wiring. It does not change any WIT interface, IPC schema, or capability contract, so it does not require an RFC.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecuritySecurity issue or hardening

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions