diff --git a/.github/workflows/Build.MacOS.Job.yml b/.github/workflows/Build.MacOS.Job.yml index 8e36298b1..634cef6dd 100644 --- a/.github/workflows/Build.MacOS.Job.yml +++ b/.github/workflows/Build.MacOS.Job.yml @@ -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 + - 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 diff --git a/build-mac.sh b/build-mac.sh index 38efcff73..f9c2bb3c0 100755 --- a/build-mac.sh +++ b/build-mac.sh @@ -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 diff --git a/docs/macos-support/seatbelt-backend.md b/docs/macos-support/seatbelt-backend.md index 16252553f..b41f433e1 100644 --- a/docs/macos-support/seatbelt-backend.md +++ b/docs/macos-support/seatbelt-backend.md @@ -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. | diff --git a/docs/schema.md b/docs/schema.md index d9cb3dc17..5fe9d7849 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -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: @@ -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) diff --git a/schemas/dev/mxc-config.schema.0.8.0-dev.json b/schemas/dev/mxc-config.schema.0.8.0-dev.json index bf42b24e7..7a4ba5c14 100644 --- a/schemas/dev/mxc-config.schema.0.8.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.8.0-dev.json @@ -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" diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index d6be5a8d4..eb043fe90 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -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; } diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts index de029318b..e86ef8158 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -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; /** diff --git a/sdk/node/tests/integration/macos-seatbelt.test.ts b/sdk/node/tests/integration/macos-seatbelt.test.ts index 8bf09f452..df90b6e32 100644 --- a/sdk/node/tests/integration/macos-seatbelt.test.ts +++ b/sdk/node/tests/integration/macos-seatbelt.test.ts @@ -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'; @@ -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')); }); diff --git a/sdk/node/tests/integration/package.test.ts b/sdk/node/tests/integration/package.test.ts index fbdb1e693..2e59a34f4 100644 --- a/sdk/node/tests/integration/package.test.ts +++ b/sdk/node/tests/integration/package.test.ts @@ -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 = { +const expectedFiles: Record = { 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( @@ -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; @@ -56,7 +57,17 @@ 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; } @@ -64,12 +75,12 @@ describe('SDK package binaries', () => { 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(', ')}`, ); }); }); diff --git a/sdk/node/tests/integration/test-helpers.ts b/sdk/node/tests/integration/test-helpers.ts index 77da91bd0..319987463 100644 --- a/sdk/node/tests/integration/test-helpers.ts +++ b/sdk/node/tests/integration/test-helpers.ts @@ -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 = [ @@ -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, +]; + // Binaries that are optional (feature-gated or only present in certain builds) // but still legitimate if found in the package. const OPTIONAL_BINARIES = [ @@ -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, ]; diff --git a/src/backends/seatbelt/common/src/profile_builder.rs b/src/backends/seatbelt/common/src/profile_builder.rs index 229a53e9d..baee79a1a 100644 --- a/src/backends/seatbelt/common/src/profile_builder.rs +++ b/src/backends/seatbelt/common/src/profile_builder.rs @@ -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 @@ -43,6 +49,7 @@ pub fn build_profile_with_proxy( request: &ExecutionRequest, proxy_address: Option<&ProxyAddress>, ) -> Result { + #[cfg(debug_assertions)] if let Some(override_profile) = request .seatbelt .as_ref() @@ -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()]; @@ -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(); diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 039111d3e..47a7beacf 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -642,7 +642,16 @@ fn validate_experimental_backend_keys( } /// Convert a typed `wire::Seatbelt` block into the validated domain struct. -fn make_seatbelt_config(sb: wire::Seatbelt) -> SeatbeltConfig { +/// +/// `profileOverride` replaces the entire generated deny-default profile, so it +/// is a catastrophic escape hatch: release builds **reject** a config that sets +/// it, so a shipped binary can never run under a caller-supplied profile and +/// never silently substitutes a different one. It is honored only in dev/debug +/// builds for advanced testing. +fn make_seatbelt_config( + sb: wire::Seatbelt, + logger: &mut Logger, +) -> Result { // Destructure (no `..`) so adding a wire field without mapping it is a // compile error rather than a silent runtime drop. let wire::Seatbelt { @@ -653,14 +662,35 @@ fn make_seatbelt_config(sb: wire::Seatbelt) -> SeatbeltConfig { keychain_access, extra_mach_lookups, } = sb; - SeatbeltConfig { + + // SECURITY: reject the whole-profile override in release builds rather than + // dropping it. Silently substituting the generated profile would run the + // caller under a policy they did not ask for -- which can be *more* + // permissive than the custom profile they supplied. Failing closed matches + // how the reserved learning-mode capabilities are handled. + #[cfg(not(debug_assertions))] + let profile_override: Option = { + if profile_override.is_some() { + let msg = "seatbelt.profileOverride is a dev-only capability and is \ + not accepted by release builds; remove it from the config" + .to_string(); + logger.log_line(&format!("SECURITY: {msg}")); + return Err(WxcError::ConfigParse(msg)); + } + None + }; + // Touch `logger` in debug so the signature is consistent across profiles. + #[cfg(debug_assertions)] + let _ = &logger; + + Ok(SeatbeltConfig { profile_override, gui_access: gui_access.unwrap_or(false), launch_method: launch_method.map(Into::into).unwrap_or_default(), nested_pty: nested_pty.unwrap_or(true), keychain_access: keychain_access.unwrap_or(false), extra_mach_lookups: extra_mach_lookups.unwrap_or_default(), - } + }) } /// Resolve the optional `containment` wire enum to a concrete domain backend. @@ -1554,7 +1584,10 @@ fn convert_wire_config( // Top-level `seatbelt` config. Configs using `experimental.seatbelt` are // rejected above. - let seatbelt = cfg.seatbelt.map(make_seatbelt_config); + let seatbelt = cfg + .seatbelt + .map(|sb| make_seatbelt_config(sb, logger)) + .transpose()?; // UI section. Capture presence before the typed mapping consumes `ui`: // `UiPolicy::default()` is full lockdown, so an explicit lockdown `ui` is @@ -5755,7 +5788,8 @@ mod tests { } #[test] - fn seatbelt_profile_override_passed_through() { + #[cfg(debug_assertions)] + fn seatbelt_profile_override_passed_through_in_debug() { let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"profileOverride": "(version 1)(deny default)"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5768,6 +5802,31 @@ mod tests { ); } + #[test] + #[cfg(not(debug_assertions))] + fn seatbelt_profile_override_rejected_in_release() { + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt", "seatbelt": {"profileOverride": "(version 1)(allow default)"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true) + .expect_err("release builds must reject profileOverride, not silently drop it"); + let msg = err.to_string(); + assert!( + msg.contains("seatbelt.profileOverride"), + "error must name the offending field, got: {msg}" + ); + assert!( + msg.contains("dev-only") && msg.contains("not accepted"), + "error must state the dev-only rejection, got: {msg}" + ); + let out = logger.get_buffer(); + assert!( + out.contains("SECURITY: seatbelt.profileOverride"), + "a SECURITY line must name the field, got: {out}" + ); + } + #[test] fn seatbelt_nested_pty_defaults_to_true_when_block_present_but_field_absent() { // seatbelt block is present but nestedPty is not specified; diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index 55bcce8d6..ce1c1ad34 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -420,7 +420,7 @@ pub enum ClipboardPolicy { #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Seatbelt { - /// Replace the generated profile entirely (advanced/testing escape hatch). + /// Replace the generated profile entirely (dev-only escape hatch; rejected by release builds). pub profile_override: Option, /// Allow GUI (WindowServer) access. pub gui_access: Option,