Skip to content
Open
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: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ Per-backend guides:
- `docs/base-process-container/guide.md` β€” process container (Windows AppContainer / BaseContainer)
- `docs/base-process-container/UIPolicy_Schema.md` β€” UI policy schema (JOB_OBJECT_UILIMIT_* mappings)
- `docs/lxc-support/lxc-backend.md` β€” LXC container backend (Linux)
- `docs/macos-support/seatbelt-backend.md` β€” macOS Seatbelt backend (experimental)
- `docs/macos-support/seatbelt-backend.md` β€” macOS Seatbelt backend
- `docs/windows-sandbox/windows-sandbox.md` / `docs/windows-sandbox/windows-sandbox-reference.md` β€” Windows Sandbox backend
- `docs/wsl/wsl-container-getting-started.md` / `docs/wsl/wsl-container-support-plan.md` β€” WSL Container (WSLC SDK)
- `docs/nanvix-microvm/nanvix.md` / `docs/nanvix-microvm/nanvix-integration-plan.md` β€” MicroVM via NanVix
Expand Down
38 changes: 16 additions & 22 deletions docs/macos-support/seatbelt-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,9 @@ You should see sandbox profile generation output followed by

The macOS sandbox backend uses the same JSON configuration schema as the
other backends, with `containment` set to `"seatbelt"`. Backend-specific
settings live under `experimental.seatbelt`, and the `--experimental`
flag is required to enable the backend at runtime:
settings live under a top-level `seatbelt` key (preferred) or under
Copy link
Copy Markdown
Member

@MGudgin MGudgin Jun 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR seems to go against what is laid out in docs\versioning.md

`experimental.seatbelt` (deprecated, still accepted for backward
compatibility):

```json
{
Expand All @@ -152,10 +153,8 @@ flag is required to enable the backend at runtime:
"defaultPolicy": "block",
"allowedHosts": ["api.github.com"]
},
"experimental": {
"seatbelt": {
"mode": "exec"
}
"seatbelt": {
"nestedPty": true
}
}
```
Expand All @@ -164,11 +163,11 @@ flag is required to enable the backend at runtime:

| Field | Type | Default | Description |
|---|---|---|---|
| `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. |
| `seatbelt.profileOverride` | string | unset | Optional override of the generated TinyScheme sandbox profile. When set, the SDK-generated profile is replaced with this raw TinyScheme string verbatim β€” all `filesystem`/`network`/`ui` policy fields are ignored for profile generation (they are still type-checked). Use this only when the auto-generated profile is insufficient. |
| `seatbelt.guiAccess` | boolean | `false` | When `true`, adds wildcard Mach service and IOKit rules so GUI applications can create windows and render via WindowServer. Requires `ui.disable: false`. Native AppKit apps (e.g. Terminal.app) work well; Electron-based apps may escape the sandbox via re-launch patterns. |
| `seatbelt.launchMethod` | `"exec"` \| `"open"` | `"exec"` | How to launch the sandboxed process. `"exec"` (default) uses the `sandbox_init()` API in `pre_exec` then execs the command directly β€” works for third-party GUI apps (Alacritty, etc.) and all CLI commands. `"open"` launches Terminal.app via LaunchServices (`open -n -W -a Terminal`) then applies the sandbox to the inner shell via the `sandbox-exec` CLI tool. This is required because Terminal.app enforces Apple Launch Constraints that kill it when exec'd by unauthorized parents. Currently only Terminal.app is supported with the `"open"` method β€” other Apple system apps (Calculator, TextEdit) cannot be sandboxed due to Launch Constraints and lack of an inner shell to constrain. |
| `seatbelt.nestedPty` | boolean | `true` | When `true`, the inner process can allocate its own pseudo-terminals via `posix_openpt`. Required by anything that spawns a shell (test runners, `git`, `gh`, REPLs, agent tools that wrap commands in a pty). Adds `(allow pseudo-tty)` and read/write/ioctl on `/dev/ptmx` to the generated profile. Set to `false` for a tighter sandbox when the inner command does not need to allocate new ttys. |
| `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 Expand Up @@ -214,22 +213,18 @@ SDK rejects it with a clear error, mirroring the Linux behavior.

### Command line

The `seatbelt` backend is currently experimental, so every invocation
must include the `--experimental` flag. Without it, the binary refuses to
run with a clear error.

```bash
# Run with config file
./mxc-exec-mac --experimental config.json
./mxc-exec-mac config.json

# Run with base64-encoded config
./mxc-exec-mac --experimental --config-base64 <base64-string>
./mxc-exec-mac --config-base64 <base64-string>

# Validate the config and exit without executing
./mxc-exec-mac --experimental --dry-run config.json
./mxc-exec-mac --dry-run config.json

# Diagnostic output to console + file
./mxc-exec-mac --experimental --debug --log-file mxc.log config.json
./mxc-exec-mac --debug --log-file mxc.log config.json
```

### SDK
Expand All @@ -248,9 +243,8 @@ const policy: SandboxPolicy = {
};

// On macOS, spawnSandbox automatically resolves to mxc-exec-mac and
// builds a seatbelt config. The backend is experimental, so the
// caller must opt in via SandboxSpawnOptions.experimental.
const pty = spawnSandbox('echo hello', policy, { experimental: true });
// builds a seatbelt config.
const pty = spawnSandbox('echo hello', policy);
pty.onData((data) => console.log(data));
pty.onExit((e) => console.log('Exit:', e.exitCode));
```
Expand Down
4 changes: 2 additions & 2 deletions schemas/dev/mxc-config.schema.0.7.0-dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"bubblewrap"
],
"default": "process",
"description": "Containment value to use for execution. Accepts both abstract intents ('process', 'vm') and concrete backends ('processcontainer', 'windows_sandbox', 'lxc', 'microvm', 'hyperlight', 'wslc', 'seatbelt', 'isolation_session', 'bubblewrap'). The native binary resolves abstract intents to a concrete backend at run time based on host capabilities (e.g., 'process' resolves to ProcessContainer on Windows, Bubblewrap on Linux, Seatbelt on macOS; 'vm' resolves to Windows Sandbox on Windows). 'lxc' is treated as a full Linux container and is only selected when explicitly requested. Note: 'microvm', 'windows_sandbox', 'wslc', 'seatbelt', 'isolation_session', and 'hyperlight' are experimental and require the --experimental CLI flag. 'hyperlight' and 'bubblewrap' have no per-backend configuration block; they share the common filesystem/network policy fields. The legacy aliases 'appcontainer' and 'macos_sandbox' are still accepted by the parser (with a deprecation log line) but are intentionally omitted from this enum so editors steer authors toward the canonical names."
"description": "Containment value to use for execution. Accepts both abstract intents ('process', 'vm') and concrete backends ('processcontainer', 'windows_sandbox', 'lxc', 'microvm', 'hyperlight', 'wslc', 'seatbelt', 'isolation_session', 'bubblewrap'). The native binary resolves abstract intents to a concrete backend at run time based on host capabilities (e.g., 'process' resolves to ProcessContainer on Windows, Bubblewrap on Linux, Seatbelt on macOS; 'vm' resolves to Windows Sandbox on Windows). 'lxc' is treated as a full Linux container and is only selected when explicitly requested. Note: 'microvm', 'windows_sandbox', 'wslc', 'isolation_session', and 'hyperlight' are experimental and require the --experimental CLI flag. 'hyperlight' and 'bubblewrap' have no per-backend configuration block; they share the common filesystem/network policy fields. The legacy aliases 'appcontainer' and 'macos_sandbox' are still accepted by the parser (with a deprecation log line) but are intentionally omitted from this enum so editors steer authors toward the canonical names."
},

"phase": {
Expand Down Expand Up @@ -387,7 +387,7 @@
},
"seatbelt": {
"type": "object",
"description": "macOS sandbox backend (experimental). Used when containment is 'seatbelt'.",
"description": "macOS sandbox backend configuration. Used when containment is 'seatbelt'.",
"properties": {
Comment thread
richiemsft marked this conversation as resolved.
"profileOverride": {
"type": "string",
Expand Down
9 changes: 3 additions & 6 deletions sdk/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,18 +122,15 @@ function buildLinuxProcessConfig(
*
* The seatbelt backend's `sandbox-exec` reads a TinyScheme profile
* generated server-side by `seatbelt_common::profile_builder`, so the SDK
* only needs to set the containment type and the mode selector under the
* experimental block β€” the policy fields on `ContainerConfig` (filesystem /
* only needs to set the containment type and ensure the top-level `seatbelt`
* config block exists β€” the policy fields on `ContainerConfig` (filesystem /
* network / ui) drive the actual rules.
*/
function buildDarwinProcessConfig(
config: ContainerConfig,
): ContainerConfig {
config.containment = 'seatbelt';
config.experimental = {
...(config.experimental ?? {}),
seatbelt: {},
};
config.seatbelt = config.seatbelt ?? {};
Comment thread
richiemsft marked this conversation as resolved.
return config;
}

Expand Down
15 changes: 11 additions & 4 deletions sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export type ContainmentBackend =
* Containment values (abstract intent or concrete backend) that require
* the `--experimental` flag.
*/
export const ExperimentalBackends: readonly (ContainmentType | ContainmentBackend)[] = ['microvm', 'windows_sandbox', 'hyperlight', 'wslc', 'seatbelt', 'isolation_session'];
export const ExperimentalBackends: readonly (ContainmentType | ContainmentBackend)[] = ['microvm', 'windows_sandbox', 'hyperlight', 'wslc', 'isolation_session'];

/**
* Clipboard access policy levels
Expand Down Expand Up @@ -276,9 +276,16 @@ export interface ContainerConfig {
experimental?: {
/** WSLC SDK configuration for Linux containers from Windows */
wslc?: WslcConfig;
/** macOS sandbox configuration (macOS only) */
/**
* macOS sandbox configuration (macOS only).
* @deprecated Use the top-level {@link seatbelt} field instead. This
* location is retained for backward compatibility and will be removed
* in a future schema version.
*/
seatbelt?: SeatbeltConfig;
};
/** macOS Seatbelt sandbox configuration (macOS only) */
seatbelt?: SeatbeltConfig;
/** Cross-platform UI configuration */
ui?: UiConfig;
}
Expand Down Expand Up @@ -348,8 +355,8 @@ export interface LxcConfig {
}

/**
* macOS Seatbelt sandbox configuration (experimental). Used under
* `experimental.seatbelt` when containment is `'seatbelt'`.
* macOS Seatbelt sandbox configuration. Used under the top-level
* `seatbelt` key (preferred) or `experimental.seatbelt` (deprecated).
*/
Comment thread
richiemsft marked this conversation as resolved.
export interface SeatbeltConfig {
/**
Expand Down
17 changes: 2 additions & 15 deletions sdk/tests/unit/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1111,26 +1111,13 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk
);
});

it('should require experimental mode for "macos_sandbox" (mirrors seatbelt gating)', () => {
// Regression: pre-fix, ExperimentalBackends.includes('macos_sandbox') was
// false, so the legacy alias bypassed the experimental gate entirely.
// The gate must look at the resolved backend, not the raw wire value.
assert.throws(
() => resolveExecutableAndArgs(makeConfig('macos_sandbox'), { executablePath: fakeExe }),
{ message: /experimental mode/ },
);
});

it('should accept "macos_sandbox" on macOS with the experimental flag set', function (this: { skip: (reason?: string) => void }) {
it('should accept "macos_sandbox" on macOS', function (this: { skip: (reason?: string) => void }) {
if (process.platform !== 'darwin') {
this.skip('seatbelt is macOS-only');
return;
}
assert.doesNotThrow(() =>
resolveExecutableAndArgs(makeConfig('macos_sandbox'), {
executablePath: fakeExe,
experimental: true,
}),
resolveExecutableAndArgs(makeConfig('macos_sandbox'), { executablePath: fakeExe }),
);
});

Expand Down
9 changes: 0 additions & 9 deletions src/core/mxc_darwin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,6 @@ fn main() {

log_request(&request, &mut logger);

// The macOS sandbox backend is experimental β€” require the flag.
if !request.experimental_enabled {
eprintln!(
"Error: the macOS sandbox backend is experimental. \
Pass --experimental to enable it."
);
process::exit(1);
}

// The SDK should always select Seatbelt on darwin. Be lenient and
// log a note instead of failing β€” same behaviour as `lxc-exec`.
if request.containment != ContainmentBackend::Seatbelt {
Expand Down
61 changes: 59 additions & 2 deletions src/core/wxc_common/src/config_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ struct RawConfig {
network: Option<RawNetwork>,
ui: Option<RawUi>,
experimental: Option<RawExperimental>,
/// Top-level seatbelt config (preferred over experimental.seatbelt).
#[serde(alias = "macos_sandbox")]
seatbelt: Option<RawSeatbelt>,
}

// State-aware request shape. `phase` is required (no `#[serde(default)]` on
Expand Down Expand Up @@ -618,13 +621,16 @@ fn present_backend_sections(raw: &RawConfig) -> Vec<&'static str> {
if experimental.wslc.is_some() {
push(ContainmentBackend::Wslc);
}
if experimental.seatbelt.is_some() {
if experimental.seatbelt.is_some() && raw.seatbelt.is_none() {
push(ContainmentBackend::Seatbelt);
}
if experimental.isolation_session.is_some() {
push(ContainmentBackend::IsolationSession);
}
}
if raw.seatbelt.is_some() {
push(ContainmentBackend::Seatbelt);
}
sections
}

Expand Down Expand Up @@ -1211,6 +1217,26 @@ fn convert_raw_config_inner(
ExperimentalConfig::default()
};

// Top-level `seatbelt` takes precedence over `experimental.seatbelt`.
// This supports the two-phase migration: new configs use the top-level key,
// old configs still work via the experimental path.
let experimental = if let Some(raw_sb) = raw.seatbelt {
Comment on lines +1220 to +1223
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: my thinking is that we should error if the user provides both. The migration would then be either one will work but you can only specify one e.g either top level or experimental but not together. I think they should be mutually exclusive from the config creators perspective while the parser allows for both. Does that make sense?

let sb = SeatbeltConfig {
profile_override: raw_sb.profile_override,
gui_access: raw_sb.gui_access.unwrap_or(false),
launch_method: raw_sb.launch_method.unwrap_or_default(),
nested_pty: raw_sb.nested_pty.unwrap_or(true),
keychain_access: raw_sb.keychain_access.unwrap_or(false),
extra_mach_lookups: raw_sb.extra_mach_lookups.unwrap_or_default(),
};
ExperimentalConfig {
seatbelt: Some(sb),
..experimental
}
} else {
experimental
};

// UI section
if let Some(raw_ui) = raw.ui {
let clipboard = match raw_ui.clipboard.as_deref() {
Expand Down Expand Up @@ -1281,6 +1307,7 @@ fn convert_raw_state_aware(
// one-shot RawExperimental; it is preserved separately on
// ParsedStateAwareRequest as raw JSON.
experimental: None,
seatbelt: None,
};

let require_process = phase == Phase::Exec;
Expand Down Expand Up @@ -3312,6 +3339,36 @@ mod tests {
assert!(cfg.keychain_access);
}

#[test]
fn top_level_seatbelt_config_accepted() {
let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"nestedPty": false, "keychainAccess": true}}"#;
let encoded = base64_encode(json.as_bytes());
let mut logger = test_logger();

let req = load_request(&encoded, &mut logger, true).unwrap();
let cfg = req
.experimental
.seatbelt
.expect("top-level seatbelt should populate experimental.seatbelt internally");
assert!(!cfg.nested_pty);
assert!(cfg.keychain_access);
}

#[test]
fn top_level_seatbelt_takes_precedence_over_experimental() {
let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"nestedPty": false}, "experimental": {"seatbelt": {"nestedPty": true}}}"#;
let encoded = base64_encode(json.as_bytes());
let mut logger = test_logger();

let req = load_request(&encoded, &mut logger, true).unwrap();
let cfg = req
.experimental
.seatbelt
.expect("seatbelt config should be set");
// Top-level wins over experimental.seatbelt
assert!(!cfg.nested_pty);
}

// Legacy wire-name aliases. The parser accepts the pre-0.6 wire vocabulary
// (`appcontainer`, `macos_sandbox`, and the `appContainer` /
// `experimental.macos_sandbox` sub-block keys) so that configs declaring
Expand Down Expand Up @@ -3450,7 +3507,7 @@ mod tests {
assert_multi_backend_rejected(
"processcontainer",
r#""experimental": {"seatbelt": {"guiAccess": true}}"#,
"experimental.seatbelt",
"seatbelt",
);
}

Expand Down
Loading
Loading