Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .github/workflows/Build.MacOS.Job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,17 @@ jobs:
run: cargo test --locked --release --target aarch64-apple-darwin
-p mxc_darwin -p seatbelt_common -p wxc_common -p wxc_e2e_tests

- name: Record build type
run: printf 'release\n' >
src/target/aarch64-apple-darwin/release/mxc-exec-mac.build-type
Comment on lines +59 to +60

- name: Upload binaries
uses: actions/upload-artifact@v4
with:
name: mxc-binaries-aarch64-apple-darwin
path: |
src/target/aarch64-apple-darwin/release/mxc-exec-mac
src/target/aarch64-apple-darwin/release/mxc-exec-mac.build-type
src/target/aarch64-apple-darwin/release/unix-test-proxy
if-no-files-found: error
retention-days: 1
3 changes: 3 additions & 0 deletions build-mac.sh
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,14 @@ copy_binary_for_target() {
mkdir -p "$bin_dir"

local src="$SRC_DIR/target/$triple/$BUILD_TYPE/mxc-exec-mac"
local build_type_file="$bin_dir/mxc-exec-mac.build-type"
if [ -f "$src" ]; then
cp "$src" "$bin_dir/mxc-exec-mac"
chmod +x "$bin_dir/mxc-exec-mac"
printf '%s\n' "$BUILD_TYPE" > "$build_type_file"
echo "Copied $src -> $bin_dir/mxc-exec-mac"
else
rm -f "$build_type_file"
echo "Warning: $src not found, skipping copy"
fi

Expand Down
2 changes: 1 addition & 1 deletion docs/macos-support/seatbelt-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ settings live under a top-level `seatbelt` key:

| Field | Type | Default | Description |
|---|---|---|---|
| `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.profileOverride` | string | unset | **Dev-only.** Optional override of the generated TinyScheme sandbox profile. When set, the generated profile is replaced with this raw TinyScheme string verbatim. Because it bypasses the deny-default profile entirely, it is a catastrophic escape hatch: **release/shipped builds reject configs that set it at parse time (logging a `SECURITY` line) and compile the override path out**, so it is honored only in dev/debug builds. |
| `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. |
Expand Down
8 changes: 7 additions & 1 deletion docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ For development, the dev schema at
[`schemas/dev/mxc-config.schema.0.8.0-dev.json`](../schemas/dev/mxc-config.schema.0.8.0-dev.json)
includes experimental features and may change without notice.

Released stable schemas are immutable and describe the config shape accepted
when they shipped. Schema validation does not guarantee that every field remains
available in every build: for example, stable schemas still describe
`seatbelt.profileOverride`, but release builds now reject configs that set this
dev-only escape hatch.

Editors that support JSON Schema will provide autocomplete and validation when
you add a `"$schema"` reference to your config file. Use the stable schema for
production configs and the dev schema when working on experimental features:
Expand Down Expand Up @@ -103,7 +109,7 @@ production configs and the dev schema when working on experimental features:
]
},
"seatbelt": { // macOS sandbox settings (macOS only)
"profileOverride": null, // Optional raw TinyScheme profile (escape hatch)
"profileOverride": null, // Dev-only escape hatch (rejected by release builds)
"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)
Expand Down
2 changes: 1 addition & 1 deletion schemas/dev/mxc-config.schema.0.8.0-dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,7 @@
]
},
"profileOverride": {
"description": "Replace the generated profile entirely (advanced/testing escape hatch).",
"description": "Replace the generated profile entirely (dev-only escape hatch; rejected by release builds).",
"type": [
"string",
"null"
Expand Down
2 changes: 1 addition & 1 deletion sdk/node/src/generated/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ export interface Seatbelt {
*/
nestedPty?: boolean | null;
/**
* Replace the generated profile entirely (advanced/testing escape hatch).
* Replace the generated profile entirely (dev-only escape hatch; rejected by release builds).
*/
profileOverride?: string | null;
}
Expand Down
3 changes: 2 additions & 1 deletion sdk/node/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,8 @@ export interface LxcConfig {
*/
export interface SeatbeltConfig {
/**
* Optional override of the generated TinyScheme sandbox profile.
* Dev/debug-only override of the generated TinyScheme sandbox profile.
* Release builds reject configs that set this field.
*/
profileOverride?: string;
/**
Expand Down
51 changes: 39 additions & 12 deletions sdk/node/tests/integration/macos-seatbelt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,23 @@ import {
debugSpawnOptions,
NETWORK_TEST_URL,
createTempDir,
getSeatbeltBuildType,
spawnFromConfigAsync,
} from './test-helpers.js';

const seatbeltSpawnOptions = { ...debugSpawnOptions, experimental: true };
const seatbeltBuildType = os.platform() === 'darwin'
? getSeatbeltBuildType()
: undefined;
const unknownBuildTypeReason = seatbeltBuildType === undefined
? 'Seatbelt build type is unknown; run build-mac.sh or set MXC_SEATBELT_BUILD_TYPE'
: undefined;
const releaseBuildSkipReason = seatbeltBuildType === 'release'
? undefined
: unknownBuildTypeReason ?? 'Requires a release mxc-exec-mac build';
const debugBuildSkipReason = seatbeltBuildType === 'debug'
? undefined
: unknownBuildTypeReason ?? 'Requires a debug mxc-exec-mac build';

// Seatbelt requires at least schema 0.5.0; the corpus floor is now 0.6.0-alpha.
const schemaVersion = '0.6.0-alpha';
Expand Down Expand Up @@ -240,23 +254,36 @@ describe('macOS Seatbelt Container', {
);
});

it('should apply profile override from seatbelt config', { timeout: 30_000 }, async () => {
// Build a config with a custom seatbelt profile that allows everything
it('should reject profile override in release builds', {
timeout: 30_000,
skip: releaseBuildSkipReason,
}, async () => {
const config = sdk.createConfigFromPolicy({ version: schemaVersion });
config.process = { commandLine: "echo 'profile override must not run'" };
config.seatbelt = { profileOverride: '(version 1)\n(allow default)' };
config.containerId = 'seatbelt-profile-override';

const result = await spawnFromConfigAsync(config, seatbeltSpawnOptions);
assert.notStrictEqual(result.exitCode, 0, 'release builds must reject profileOverride');
assert.ok(
result.stdout.includes('seatbelt.profileOverride') &&
result.stdout.includes('dev-only') &&
result.stdout.includes('not accepted'),
`Expected profileOverride rejection, got: ${result.stdout}`,
);
assert.ok(!result.stdout.includes('profile override must not run'));
});

it('should apply profile override in debug builds', {
timeout: 30_000,
skip: debugBuildSkipReason,
}, async () => {
const config = sdk.createConfigFromPolicy({ version: schemaVersion });
config.process = { commandLine: "echo 'profile override works'" };
config.seatbelt = { profileOverride: '(version 1)\n(allow default)' };
config.containerId = 'seatbelt-profile-override';

const result = await new Promise<{ exitCode: number; stdout: string }>((resolve, reject) => {
const ptyProcess = sdk.spawnSandboxFromConfig(config, seatbeltSpawnOptions);
let stdout = '';
const timer = setTimeout(() => reject(new Error('Test timed out waiting for onExit')), 25_000);
ptyProcess.onData((data: string) => { stdout += data; });
ptyProcess.onExit((event: { exitCode: number }) => {
clearTimeout(timer);
resolve({ exitCode: event.exitCode, stdout });
});
});
const result = await spawnFromConfigAsync(config, seatbeltSpawnOptions);
assert.strictEqual(result.exitCode, 0, `Expected exit 0: ${result.stdout}`);
assert.ok(result.stdout.includes('profile override works'));
});
Expand Down
41 changes: 26 additions & 15 deletions sdk/node/tests/integration/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,23 @@ import {
getSdkBinDir,
EXPECTED_WINDOWS_BINARIES,
EXPECTED_LINUX_BINARIES,
EXPECTED_MACOS_BINARIES,
ALL_KNOWN_BINARIES,
EXPECTED_MACOS_PACKAGE_FILES,
ALL_KNOWN_PACKAGE_FILES,
getSeatbeltBuildType,
platformName,
} from './test-helpers.js';

const expectedBinaries: Record<string, string[]> = {
const expectedFiles: Record<string, string[]> = {
win32: EXPECTED_WINDOWS_BINARIES,
linux: EXPECTED_LINUX_BINARIES,
darwin: EXPECTED_MACOS_BINARIES,
darwin: EXPECTED_MACOS_PACKAGE_FILES,
};

describe('SDK package binaries', () => {
const binDir = getSdkBinDir();
const platform = os.platform();
const osName = platformName();
const expected = expectedBinaries[platform] ?? [];
const expected = expectedFiles[platform] ?? [];

it('should have a bin directory for the current architecture', () => {
assert.ok(
Expand All @@ -34,17 +35,17 @@ describe('SDK package binaries', () => {
);
});

for (const binary of expected) {
it(`should include ${binary}`, () => {
const fullPath = path.join(binDir, binary);
for (const file of expected) {
it(`should include ${file}`, () => {
const fullPath = path.join(binDir, file);
assert.ok(
fs.existsSync(fullPath),
`Expected binary not found: ${fullPath}`,
`Expected package file not found: ${fullPath}`,
);
});
}

it(`should have all ${osName} binaries present`, () => {
it(`should have all ${osName} package files present`, () => {
if (expected.length === 0) {
// No binary expectations for this platform — skip
return;
Expand All @@ -56,20 +57,30 @@ describe('SDK package binaries', () => {
);
});

it('should not contain unexpected binaries', () => {
it('should identify the packaged Seatbelt build type', {
skip: platform !== 'darwin',
}, () => {
const buildType = getSeatbeltBuildType();
assert.ok(
buildType === 'debug' || buildType === 'release',
'Expected a valid mxc-exec-mac build-type marker',
);
});

it('should not contain unexpected files', () => {
if (!fs.existsSync(binDir)) {
return;
}
const actual = fs.readdirSync(binDir).filter(f => {
const stat = fs.statSync(path.join(binDir, f));
return stat.isFile();
});
// The npm package bundles binaries for all platforms in the same arch
// directory, so allow any known binary regardless of current OS.
const unexpected = actual.filter(f => !ALL_KNOWN_BINARIES.includes(f));
// The npm package bundles files for all platforms in the same arch
// directory, so allow any known entry regardless of current OS.
const unexpected = actual.filter(f => !ALL_KNOWN_PACKAGE_FILES.includes(f));
assert.deepStrictEqual(
unexpected, [],
`Unexpected binaries in ${binDir} — add them to the expected lists in test-helpers.ts: ${unexpected.join(', ')}`,
`Unexpected files in ${binDir} — add them to the expected lists in test-helpers.ts: ${unexpected.join(', ')}`,
);
});
});
49 changes: 44 additions & 5 deletions sdk/node/tests/integration/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,40 @@ export function getSdkBinDir(): string {
return path.join(getSdkPackageRoot(), 'bin', arch);
}

export const SEATBELT_BUILD_TYPE_FILENAME = 'mxc-exec-mac.build-type';
export type SeatbeltBuildType = 'debug' | 'release';

/**
* Return the build profile of the packaged Seatbelt executor.
*
* MXC_SEATBELT_BUILD_TYPE supports manually supplied executables that do not
* carry the marker written by build-mac.sh and the macOS CI build.
*/
export function getSeatbeltBuildType(): SeatbeltBuildType | undefined {
const override = process.env.MXC_SEATBELT_BUILD_TYPE;
if (override !== undefined) {
if (override === 'debug' || override === 'release') {
return override;
}
throw new Error(
`MXC_SEATBELT_BUILD_TYPE must be "debug" or "release", got "${override}"`,
);
}

const markerPath = path.join(getSdkBinDir(), SEATBELT_BUILD_TYPE_FILENAME);
if (!fs.existsSync(markerPath)) {
return undefined;
}

const buildType = fs.readFileSync(markerPath, 'utf8').trim();
if (buildType === 'debug' || buildType === 'release') {
return buildType;
}
throw new Error(
`Invalid Seatbelt build type in ${markerPath}: "${buildType}"`,
);
}

// Expected package binaries

export const EXPECTED_WINDOWS_BINARIES = [
Expand All @@ -65,6 +99,11 @@ export const EXPECTED_MACOS_BINARIES = [
'unix-test-proxy',
];

export const EXPECTED_MACOS_PACKAGE_FILES = [
...EXPECTED_MACOS_BINARIES,
SEATBELT_BUILD_TYPE_FILENAME,
];
Comment on lines +102 to +105

// Binaries that are optional (feature-gated or only present in certain builds)
// but still legitimate if found in the package.
const OPTIONAL_BINARIES = [
Expand All @@ -74,13 +113,13 @@ const OPTIONAL_BINARIES = [
// only when the plm crate is included in the build.
];

// Combined list of all known binaries across platforms. The npm package
// bundles both Windows and Linux binaries in the same arch directory, so
// the "no unexpected binaries" check must allow binaries from either OS.
export const ALL_KNOWN_BINARIES = [
// Combined list of all known bin-directory files across platforms. The npm
// package bundles files for multiple platforms in the same architecture
// directory, so the "no unexpected files" check must allow every known entry.
export const ALL_KNOWN_PACKAGE_FILES = [
...EXPECTED_WINDOWS_BINARIES,
...EXPECTED_LINUX_BINARIES,
...EXPECTED_MACOS_BINARIES,
...EXPECTED_MACOS_PACKAGE_FILES,
...OPTIONAL_BINARIES,
];

Expand Down
36 changes: 36 additions & 0 deletions src/backends/seatbelt/common/src/profile_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ use wxc_common::models::{
/// Build a complete Seatbelt sandbox profile, scoping cooperative proxy
/// reachability to the resolved address supplied by the runner.
///
/// If `request.seatbelt.profile_override` is set, that string is returned
/// verbatim and policy fields are ignored. This whole-profile escape hatch is
/// **dev-only**: the branch is compiled out of release builds (and the config
/// parser rejects configs that set `profileOverride` in release anyway), so a
/// shipped binary always builds the generated deny-default profile.
///
/// `pub` (not `pub(crate)`) so it stays a reachable API root: `profile_builder`
/// is compiled and unit-tested on every host, but its only in-crate caller
/// (`seatbelt_runner`) is `cfg(target_os = "macos")`, so on other targets a
Expand All @@ -43,6 +49,7 @@ pub fn build_profile_with_proxy(
request: &ExecutionRequest,
proxy_address: Option<&ProxyAddress>,
) -> Result<String, String> {
#[cfg(debug_assertions)]
if let Some(override_profile) = request
.seatbelt
.as_ref()
Expand Down Expand Up @@ -945,6 +952,7 @@ mod tests {
}

#[test]
#[cfg(debug_assertions)]
fn profile_override_takes_precedence() {
let mut r = req();
r.policy.readonly_paths = vec!["/should/be/ignored".into()];
Expand All @@ -957,6 +965,34 @@ mod tests {
assert_eq!(p, "(version 1)(allow default)");
}

#[test]
#[cfg(not(debug_assertions))]
fn profile_override_branch_is_absent_in_release() {
// Defense in depth: even if a release binary somehow reaches the builder
// with an override populated (the parser rejects it first), the override
// branch is compiled out, so the generated deny-default profile wins.
let mut r = req();
r.policy.readonly_paths = vec!["/should/be/honored".into()];
r.seatbelt = Some(SeatbeltConfig {
profile_override: Some("(version 1)(allow default)".into()),
gui_access: false,
..Default::default()
});
let p = build_profile(&r).unwrap();
assert_ne!(
p, "(version 1)(allow default)",
"release builds must not honor a caller-supplied profile"
);
assert!(
p.contains("(deny default)"),
"generated profile must keep its deny-default baseline, got: {p}"
);
assert!(
p.contains("/should/be/honored"),
"generated profile must reflect the request policy, got: {p}"
);
}

#[test]
fn paths_with_quotes_and_backslashes_are_escaped() {
let mut r = req();
Expand Down
Loading
Loading