Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/macos-support/seatbelt-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ flag is required to enable the backend at runtime:
| `experimental.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. |
| `experimental.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. |
| `experimental.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. |
| `experimental.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. |
| `experimental.seatbelt.keychainAccess` | boolean | `false` | When `true`, opens the sandbox enough for `keytar` / `Security.framework` to reach the macOS Keychain end-to-end. Adds Mach lookup for `com.apple.SecurityServer`, `com.apple.securityd`, `com.apple.trustd`, `com.apple.ocspd`, `com.apple.cfprefsd.daemon`, `com.apple.xpcd`, and the `com.apple.lsd.*` family (regex); read access to `/private/var/db/mds` (Spotlight/MDS metadata) and `/private/var/protected/trustd` (trustd protected store); and read+write access to `~/Library/Keychains` (user keychain DB) and `/private/var/folders` (XPC cache and per-user containers). The system keychain stores under `/Library/Keychains` and `/System/Library/Keychains` are already covered by the baseline `/Library` and `/System` read-only allows. Off by default β€” opt in only when the inner workload genuinely needs Keychain access. |

### Filesystem policy

Expand Down
6 changes: 5 additions & 1 deletion docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ production configs and the dev schema when working on experimental features:
"storagePath": "C:\\wslc-storage" // Image store path
},
"seatbelt": { // macOS sandbox settings (macOS only)
"profileOverride": null // Optional raw TinyScheme profile (escape hatch)
"profileOverride": null, // Optional raw TinyScheme profile (escape hatch)
"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)
"keychainAccess": false // Allow Keychain via securityd / trustd / cfprefsd / lsd.*
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions schemas/dev/mxc-config.schema.0.6.0-dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,16 @@
"enum": ["exec", "open"],
"default": "exec",
"description": "How to launch the sandboxed process. 'exec' (default) uses sandbox_init + exec and works for third-party GUI apps (e.g. Alacritty). 'open' uses LaunchServices ('open -n -W -a Terminal') to satisfy Apple Launch Constraints, then applies the sandbox to the inner shell via sandbox-exec. Required for system apps like Terminal.app that enforce launch constraints."
},
"nestedPty": {
"type": "boolean",
"default": true,
"description": "Allow the inner process to allocate its own pseudo-terminals via posix_openpt (needed by tests, git, gh, REPLs and other tools that spawn a shell). Adds (allow pseudo-tty) and read/write/ioctl on /dev/ptmx. Defaults to true; set to false for the tightest possible sandbox when the inner command does not need to allocate new ttys."
},
"keychainAccess": {
"type": "boolean",
"default": false,
"description": "Allow the inner process to use the macOS Keychain (e.g. via keytar or Security.framework) end-to-end. Adds Mach lookup for com.apple.SecurityServer, com.apple.securityd, com.apple.trustd, com.apple.ocspd, com.apple.cfprefsd.daemon, com.apple.xpcd and the com.apple.lsd.* family; read access to /private/var/db/mds and /private/var/protected/trustd; and read+write access to ~/Library/Keychains and /private/var/folders (XPC cache). System keychain stores under /Library and /System/Library are already covered by the baseline read-only allows. Defaults to false; opt in only when the inner workload genuinely needs Keychain access."
}
}
},
Expand Down
20 changes: 20 additions & 0 deletions sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,26 @@ export interface SeatbeltConfig {
* Optional override of the generated TinyScheme sandbox profile.
*/
profileOverride?: string;
/**
* Allow the inner process to allocate its own pseudo-terminals via
* `posix_openpt` (needed by tests, `git`, `gh`, REPLs, and any tool
* that wraps commands in a pty). Adds `(allow pseudo-tty)` and
* read/write/ioctl on `/dev/ptmx` to the generated profile. Defaults
* to `true`; set to `false` for the tightest possible sandbox when
* the inner command does not need to allocate new ttys.
*/
nestedPty?: boolean;
/**
* Allow the inner process to use the macOS Keychain (e.g. via
* `keytar` or `Security.framework`) end-to-end. Adds Mach lookup for
* `securityd`, `trustd`, `ocspd`, `cfprefsd`, `xpcd`, and the
* `com.apple.lsd.*` family; read access to `/private/var/db/mds` and
* `/private/var/protected/trustd`; and read+write access to
* `~/Library/Keychains` and `/private/var/folders` (XPC cache).
* Defaults to `false`; opt in only when the inner workload genuinely
* needs Keychain access.
*/
keychainAccess?: boolean;
}

/**
Expand Down
220 changes: 212 additions & 8 deletions src/mxc_pty/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,55 @@ pub enum PtyOutcome {
/// streamed to the host stdio by the time this function returns;
/// callers needing captured output should write it to a file in cwd
/// and read it back from there.
///
/// When fd 0 is itself a tty (i.e. the executor binary is being driven
/// by a parent that wrapped it in a pty β€” the common case for the
/// `mxc-sdk` host), we put that outer slave into raw mode for the
/// duration of the bridge. Without this, the kernel termios on the
/// outer pty echoes back any bytes the host writes to its master and
/// renders control chars as `^X` on the way through, which corrupts
/// any TUI the inner child renders (e.g. terminal palette query
/// responses get echoed instead of forwarded as input).
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn run_with_pty(mut command: Command, options: PtyOptions) -> Result<PtyOutcome, String> {
use std::io::{Read, Write};
use std::os::unix::io::AsRawFd;
use std::process::Stdio;
use std::sync::mpsc;
use std::thread;
use std::time::Instant;

use nix::pty::openpty;

let pty_pair = openpty(None, None).map_err(|e| format!("openpty failed: {}", e))?;
// Put our own stdin (the outer pty slave, if any) into raw mode so
// input bytes pass through to the inner pty without local echo or
// canonical-mode line buffering. The guard restores the original
// termios on drop β€” important because mxc-exec-mac continues to
// print to stdout after `run_with_pty` returns.
let _outer_raw_guard = RawSlaveGuard::install(std::io::stdin().as_raw_fd());

// Inherit the outer pty's window size so the inner child renders at
// the host terminal's actual dimensions instead of macOS' default
// 0Γ—0 (which silently breaks any TUI). When fd 0 is not a tty (CI,
// pipe, file redirect) we leave the inner pty at its kernel
// default β€” interactive TUIs aren't useful in that case anyway.
let outer_winsize = unsafe {
let mut ws: libc::winsize = std::mem::zeroed();
if libc::ioctl(0, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 && ws.ws_row > 0 {
Some(ws)
} else {
None
}
};
let inner_winsize = outer_winsize.map(|ws| nix::pty::Winsize {
ws_row: ws.ws_row,
ws_col: ws.ws_col,
ws_xpixel: ws.ws_xpixel,
ws_ypixel: ws.ws_ypixel,
});

let pty_pair =
openpty(inner_winsize.as_ref(), None).map_err(|e| format!("openpty failed: {}", e))?;

// Three duplicates of the slave fd so each Stdio takes ownership of
// its own handle; otherwise std::process::Stdio::from would consume
Expand Down Expand Up @@ -127,7 +165,12 @@ pub fn run_with_pty(mut command: Command, options: PtyOptions) -> Result<PtyOutc
//
// `unblock_signals` reverses any sigmask the parent installed (e.g.
// signal_cleanup's sigwait-blocked set) so the child doesn't
// silently ignore Ctrl-C / termination.
// silently ignore Ctrl-C / termination. SIGWINCH is unblocked
// defensively in case anyone in the parent process had it blocked;
// execve(2) resets the handler to default ("ignore" for SIGWINCH on
// both Linux and macOS) but preserves the inherited signal mask, so
// a child process running e.g. node will install its own SIGWINCH
// handler and depend on the signal not being masked.
let unblock_signals = options.unblock_signals;
// SAFETY: the closure runs after fork, before exec. Only
// async-signal-safe operations are used: `setsid`, `ioctl`, and
Expand All @@ -145,13 +188,12 @@ pub fn run_with_pty(mut command: Command, options: PtyOptions) -> Result<PtyOutc
// is what actually matters for the child.
let _ = libc::ioctl(0, libc::TIOCSCTTY as _, 0);

if !unblock_signals.is_empty() {
let mut mask = nix::sys::signal::SigSet::empty();
for sig in unblock_signals {
mask.add(*sig);
}
mask.thread_unblock().map_err(std::io::Error::from)?;
let mut mask = nix::sys::signal::SigSet::empty();
mask.add(nix::sys::signal::Signal::SIGWINCH);
for sig in unblock_signals {
mask.add(*sig);
}
mask.thread_unblock().map_err(std::io::Error::from)?;
Ok(())
});
}
Expand All @@ -171,6 +213,20 @@ pub fn run_with_pty(mut command: Command, options: PtyOptions) -> Result<PtyOutc
.map_err(|e| format!("dup master: {}", e))?;
let mut master_reader = master;

// Resize forwarder: when the host's terminal resizes, the kernel
// delivers SIGWINCH to us (because our fd 0 is the outer pty
// slave). Read the new size off fd 0 and push it to the inner pty
// master via TIOCSWINSZ β€” that delivers SIGWINCH to the inner
// child, so TUIs reflow correctly. Hand the forwarder its own
// dup of the master so the resize fd isn't tied to the lifetime
// of `master_writer` (which the input-forwarder thread can drop
// mid-session); the forwarder leaks its dup for the rest of the
// process, the same lifetime as the signal handler that targets it.
let winch_master = master_writer
.try_clone()
.map_err(|e| format!("dup master for sigwinch forwarder: {}", e))?;
let _winch_thread = spawn_sigwinch_forwarder(winch_master);

// Output forwarder: master -> host stdout. Signals "ready" on the
// first byte from inside the child so the input forwarder doesn't
// race the inner shell's `tcsetattr` init.
Expand Down Expand Up @@ -262,6 +318,154 @@ pub fn run_with_pty(mut command: Command, options: PtyOptions) -> Result<PtyOutc
Ok(outcome)
}

/// Background thread that watches for SIGWINCH on the outer pty
/// (delivered to *some* thread because fd 0 is the outer slave) and
/// forwards the new window size to the inner pty master via TIOCSWINSZ.
///
/// Uses the self-pipe pattern: an async-signal-safe SIGWINCH handler
/// writes one byte to a pipe, and a dedicated thread reads from the
/// pipe and does the ioctl dance. This works regardless of which
/// thread the kernel picks to deliver the signal to (sigwait alone is
/// not enough β€” pthread_sigmask only changes the calling thread's
/// mask, so other threads created by the runtime can swallow SIGWINCH
/// first and our sigwait blocks forever).
///
/// Best-effort: if any of the setup steps fail we just skip resize
/// propagation and the inner stays at its initial size.
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn spawn_sigwinch_forwarder(master: std::fs::File) -> Option<std::thread::JoinHandle<()>> {
use std::os::unix::io::AsRawFd;

let (read_end, write_end) = nix::unistd::pipe().ok()?;
let read_fd = read_end.as_raw_fd();
let write_fd = write_end.as_raw_fd();
let master_fd = master.as_raw_fd();
// Leak so the fds outlive every reader/writer in the process. The
// signal handler targets `write_fd` for the rest of the process,
// and `master_fd` is what we ioctl into on every resize β€” closing
// either would race.
std::mem::forget(read_end);
std::mem::forget(write_end);
std::mem::forget(master);

// Make the write end non-blocking so the signal handler can't
// deadlock on a full pipe (the comment on `sigwinch_handler` already
// assumes EAGAIN-on-full, but without O_NONBLOCK write(2) would
// actually block inside the handler instead of dropping the wakeup).
// Best-effort: if fcntl fails we stay in blocking mode β€” same as the
// previous behavior, no regression.
unsafe {
let flags = libc::fcntl(write_fd, libc::F_GETFL);
if flags >= 0 {
let _ = libc::fcntl(write_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
}

SIGWINCH_PIPE_WRITE_FD.store(write_fd, std::sync::atomic::Ordering::Release);

// SIGWINCH's default action is "ignore", so without an installed
// handler the kernel drops the signal entirely. SA_RESTART so we
// don't break unrelated syscalls.
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = sigwinch_handler as *const () as usize;
libc::sigemptyset(&mut sa.sa_mask);
sa.sa_flags = libc::SA_RESTART;
if libc::sigaction(libc::SIGWINCH, &sa, std::ptr::null_mut()) != 0 {
return None;
}
}

Some(std::thread::spawn(move || {
let mut buf = [0u8; 64];
loop {
// Read at least one byte; coalesce bursts.
let n = unsafe { libc::read(read_fd, buf.as_mut_ptr() as *mut _, buf.len()) };
if n <= 0 {
return;
}
unsafe {
let mut ws: libc::winsize = std::mem::zeroed();
if libc::ioctl(0, libc::TIOCGWINSZ, &mut ws) != 0 {
continue;
}
// Inner pty gone β€” exit the thread.
if libc::ioctl(master_fd, libc::TIOCSWINSZ, &ws) != 0 {
return;
}
}
}
}))
}

/// Write end of the SIGWINCH self-pipe. Set once during forwarder
/// installation; the handler reads this and write()s 1 byte.
#[cfg(any(target_os = "linux", target_os = "macos"))]
static SIGWINCH_PIPE_WRITE_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);

/// Async-signal-safe SIGWINCH handler. The only syscall used is write(2),
/// which is on the AS-safe list. Errors are intentionally ignored β€” if
/// the pipe is full (64 bytes pending and reader hasn't drained) we just
/// drop the redundant wakeup.
#[cfg(any(target_os = "linux", target_os = "macos"))]
extern "C" fn sigwinch_handler(_sig: libc::c_int) {
let fd = SIGWINCH_PIPE_WRITE_FD.load(std::sync::atomic::Ordering::Acquire);
if fd < 0 {
return;
}
let byte: u8 = 1;
unsafe {
let _ = libc::write(fd, &byte as *const _ as *const _, 1);
}
}

/// RAII guard that puts an outer pty slave fd into raw mode on creation
/// and restores the original termios on drop. Used by [`run_with_pty`]
/// when our own stdin is itself a pty slave (i.e. the executor is
/// running under a host-allocated pty), so that input bytes round-trip
/// to the inner child's pty cleanly without local echo or `^X`-style
/// control-char rendering corrupting the inner TUI.
///
/// Doing nothing (and dropping cleanly) is the right behaviour when
/// stdin is not a tty (piped input, redirected from a file, etc.) or
/// when termios calls fail β€” the inner child still works, just without
/// the raw-mode passthrough.
#[cfg(any(target_os = "linux", target_os = "macos"))]
struct RawSlaveGuard {
fd: std::os::unix::io::RawFd,
original: nix::sys::termios::Termios,
}

#[cfg(any(target_os = "linux", target_os = "macos"))]
impl RawSlaveGuard {
fn install(fd: std::os::unix::io::RawFd) -> Option<Self> {
use nix::sys::termios::{cfmakeraw, tcgetattr, tcsetattr, SetArg};
// SAFETY: `isatty` is async-signal-safe and only touches the
// process's own fd table.
if unsafe { libc::isatty(fd) } == 0 {
return None;
}
// nix's tcgetattr takes anything implementing AsFd.
let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
let original = tcgetattr(borrowed).ok()?;
let mut raw = original.clone();
cfmakeraw(&mut raw);
if tcsetattr(borrowed, SetArg::TCSANOW, &raw).is_err() {
return None;
}
Some(Self { fd, original })
}
}

#[cfg(any(target_os = "linux", target_os = "macos"))]
impl Drop for RawSlaveGuard {
fn drop(&mut self) {
use nix::sys::termios::{tcsetattr, SetArg};
let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(self.fd) };
let _ = tcsetattr(borrowed, SetArg::TCSANOW, &self.original);
}
}

/// Stub for the workspace-wide clippy lane that runs on Windows.
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub fn run_with_pty(_command: Command, _options: PtyOptions) -> Result<PtyOutcome, String> {
Expand Down
Loading
Loading