You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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-120if seatbelt::darwin_major_version() >= 24{
tracing::warn!("macOS 15+ detected: sandbox-exec is deprecated. Running host process unsandboxed.");returnOk(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
Remove the >= 24 guard (mod.rs:115-120).
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.
Drop the now-dead darwin_major_version helper (seatbelt.rs) and its passthrough test branch.
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.
Summary
SandboxCommand::wrapdisables the OS-level subprocess sandbox on macOS 15+ (Darwin major version >= 24) and returns the command completely unsandboxed: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-execis deprecated and therefore unusable, is incorrect:sandbox-execstill 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::wrapis the macOS arm reached fromprepare_sandboxed_command(core/crates/astrid-capsule/src/engine/wasm/host/process/managed.rs:118), the host path for thehost_processcapability. 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 atracing::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 narrowfile-read*subpath allowlist that omits(allow file-read* (literal "/")). A spawned binary such asnodestats 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")':(allow default)wrap()shape: narrow reads, nomach*, noliteral "/"(allow mach*)only(allow file-read* (literal "/"))onlyThe load-bearing fix is the
(literal "/")read rule, not(allow mach*). Addingmach*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 runssandbox-execunguarded via the MCP path today. Thewrap()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 skippingsandbox-execon macOS 15+ (Darwin >= 24) because it "crashes with SIGABRT", with no reproducer or analysis. It referenced #602, which is unrelated (acwd://VFS resolver issue), and it merged without human review.Fix
>= 24guard (mod.rs:115-120).wrap()throughbuild_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.darwin_major_versionhelper (seatbelt.rs) and its passthrough test branch.seatbelt_available()runtime probe mirroringbwrap_available()(it already runsbwrap --unshare-user ...and caches the result). For Seatbelt, probe withsandbox-exec -p '(version 1)(allow default)' /usr/bin/true. On genuine failure, route throughSandboxPolicyso it fails closed (Requiredreturns 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 ontobuild_seatbelt_prefixremoves the duplication that caused the divergence in the first place.Acceptance criteria
SandboxCommand::wrapreturns a sandboxed command on Darwin >= 24 (no unconditional passthrough).wrap()and the MCP spawn paths.nodesubprocess under the generated profile viasandbox-execand asserts exit 0 on macOS, with a regression contrast confirming that the same profile without(literal "/")aborts (the T1 versus T3 cases above).SandboxPolicy(fails closed underRequired), 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.