From 9d456d896d92e8df821e8c3bf15c16089bed5f9f Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:15:48 -0800 Subject: [PATCH 1/7] Added custom fields to seatbelt config for mach, iokit, posix --- docs/backends/seatbelt/seatbelt-backend.md | 103 ++- docs/containment-policy/dev/policy.md | 2 +- docs/development/architecture/versioning.md | 23 +- docs/schema.md | 39 +- .../dev/mxc-config.schema.1.1.0-alpha.json | 115 ++- sdk/node/src/generated/v1_1_0_alpha/wire.ts | 78 +- .../seatbelt/common/capture_analysis.rs | 3 +- .../seatbelt/common/profile_builder.rs | 674 ++++++++++++++++-- .../seatbelt/common/seatbelt_policy.rs | 555 +++++++++++++- .../seatbelt/common/seatbelt_runner.rs | 10 +- .../config_contract_adapters/dev/one_shot.rs | 69 ++ .../config_contract_adapters/v0_9/one_shot.rs | 4 + .../config_contract_adapters/v1_0/one_shot.rs | 4 + .../src/core/mxc_common/config_parser.rs | 91 ++- src/mxc-sdk/src/core/mxc_common/models.rs | 69 ++ src/mxc-sdk/src/core/mxc_common/wire.rs | 46 ++ src/mxc-sdk/src/core/mxc_contract/dev/mod.rs | 5 +- .../src/core/mxc_contract/dev/stable.rs | 82 +++ .../mxc_contract/v1_1_0_alpha/seatbelt.rs | 48 ++ ..._capture_denials_gui_app_custom_allow.json | 31 + ...belt_gui_access_custom_clipboard_none.json | 22 + ...tbelt_gui_access_custom_lookups_allow.json | 21 + ...atbelt_gui_access_custom_lookups_deny.json | 21 + .../seatbelt_gui_access_custom_posix_shm.json | 36 + .../seatbelt_gui_access_custom_registers.json | 34 + .../seatbelt_gui_access_keychain_off.json | 18 + .../seatbelt_gui_access_nested_pty_off.json | 21 + .../seatbelt_gui_access_readonly_tmp.json | 20 + .../run_seatbelt_capture_denials_test.sh | 10 + tests/scripts/run_seatbelt_gui_access_test.sh | 124 +++- tests/scripts/run_seatbelt_options_test.sh | 8 +- tests/scripts/run_seatbelt_tty_test.sh | 2 +- 32 files changed, 2263 insertions(+), 125 deletions(-) create mode 100644 tests/configs/seatbelt_capture_denials_gui_app_custom_allow.json create mode 100644 tests/configs/seatbelt_gui_access_custom_clipboard_none.json create mode 100644 tests/configs/seatbelt_gui_access_custom_lookups_allow.json create mode 100644 tests/configs/seatbelt_gui_access_custom_lookups_deny.json create mode 100644 tests/configs/seatbelt_gui_access_custom_posix_shm.json create mode 100644 tests/configs/seatbelt_gui_access_custom_registers.json create mode 100644 tests/configs/seatbelt_gui_access_keychain_off.json create mode 100644 tests/configs/seatbelt_gui_access_nested_pty_off.json create mode 100644 tests/configs/seatbelt_gui_access_readonly_tmp.json diff --git a/docs/backends/seatbelt/seatbelt-backend.md b/docs/backends/seatbelt/seatbelt-backend.md index 54bc5517d4..abc2e049b5 100644 --- a/docs/backends/seatbelt/seatbelt-backend.md +++ b/docs/backends/seatbelt/seatbelt-backend.md @@ -348,7 +348,7 @@ combined with `runtimeConfig.networkProxy`. | Policy | Generated rule | |---|---| | `ui.disable: true` (default) | Denies mach-lookup of `com.apple.windowserver.active`, `com.apple.windowserver.session`, and `com.apple.coreservices.launchservicesd` | -| `ui.clipboard: "none"` (default) | Denies mach-lookup of `com.apple.pasteboard.1` | +| `ui.clipboard: "none"` (default) | Denies mach-lookup of every name starting with `com.apple.pasteboard.` | | `ui.injection: false` (default) | Denies `iokit-open` of `IOHIDLibUserClient` | ## Seatbelt-specific options @@ -361,21 +361,27 @@ Set under a top-level `"seatbelt"` key. | `guiAccess` | bool | `false` | Adds Mach/IOKit rules so GUI apps can create windows, and widens the filesystem — see below. **Requires UI to be enabled**, which is spelled `ui.disable: false` (there is no `ui.enable`). | | `keychainAccess` | bool | `false` | Opens the sandbox enough for `keytar` / Security.framework to reach the Keychain. Opt in only if genuinely needed. | | `profileOverride` | string | unset | Replaces the generated profile with raw TinyScheme. **All `filesystem`/`network`/`ui` policy is ignored for profile generation.** MXC still appends the two [process cleanup](#process-cleanup) rules, and rejects an override that contains their `com.microsoft.mxc.run.` names. Last resort. | -| `extraMachLookups` | string[] | `[]` | Additional Mach services the sandbox may look up, as exact `global-name` values. The escape hatch for an app that needs one XPC service without resorting to `profileOverride`. Names starting with `com.microsoft.mxc.run.` are rejected. | +| `extraMachLookups` | string[] | `[]` | **Deprecated in `1.1.0-alpha`: use `customMachLookups.allow`.** Additional Mach services the sandbox may look up, as exact `global-name` values. Names starting with `com.microsoft.mxc.run.` are rejected. | +| `customMachLookups` | object | unset | **Requires config version `1.1.0-alpha`.** Mach services the sandbox may look up. See [Custom Mach, IOKit, and shared memory lists](#custom-mach-iokit-and-shared-memory-lists). | +| `customMachRegisters` | object | unset | **Requires config version `1.1.0-alpha`.** Mach services the sandbox may register, split into `global` and `local` names. See [Custom Mach, IOKit, and shared memory lists](#custom-mach-iokit-and-shared-memory-lists). | +| `customIoKits` | object | unset | **Requires config version `1.1.0-alpha`.** IOKit user-client classes the sandbox may open. See [Custom Mach, IOKit, and shared memory lists](#custom-mach-iokit-and-shared-memory-lists). | +| `customPosixShm` | object | unset | **Requires config version `1.1.0-alpha`.** POSIX shared memory regions the sandbox may read, write, or create. See [Custom Mach, IOKit, and shared memory lists](#custom-mach-iokit-and-shared-memory-lists). | | `captureDenials` | object | unset | **Requires config version `1.1.0-alpha`.** Records what the sandbox blocked into a JSON report. See [Learning mode](#learning-mode-capturedenials). |
guiAccess — exactly what it opens -All `mach-lookup` (an allowlist would be fragile — the services GUI frameworks -need vary by macOS release), plus `mach-register`, `iokit-open` for GPU/Metal, -`pseudo-tty` with `/dev/ptmx` and sandbox-authorized `/dev/ttys*` devices, and -POSIX shared memory. PTY allocation remains enabled even when `nestedPty` is -`false`. +All `mach-lookup`, plus `mach-register`, `iokit-open` for GPU/Metal, +and POSIX shared memory. A `customMachLookups`, `customMachRegisters`, +`customIoKits`, or `customPosixShm` block replaces the matching grant. It also grants read **and** write across all of `/private/tmp` and -`/private/var/folders`, regardless of your `filesystem` policy. Deny rules are -emitted last, so a `deniedPaths` entry still overrides this grant. +`/private/var/folders`. Your `filesystem` policy is applied after this grant, so +`readonlyPaths` and `deniedPaths` entries inside those directories still apply. + +Explicit settings always win over `guiAccess`: `nestedPty` alone controls PTY +creation, `keychainAccess` controls the Keychain, and the `ui` settings control +WindowServer, the pasteboard, and HID input.
@@ -442,8 +448,8 @@ with these macOS differences: `deniedResourcesTruncated: true` means some denials may be missing. Delete the report, its `.verbose.json` file, and any `.seatbelt.log` when you're done. -Requests that `guiAccess` grants, such as Mach lookups and IOKit opens, aren't -denials. They appear only in the verbose file, with `"decision": "allowGrant"`. +**Mach, IOKit, and shared memory requests**: Names granted by `guiAccess` or a custom `default: "allow"` aren't +denials; they appear only in the verbose file as `"decision": "allowGrant"`. `mxc-exec-mac --audit config.json` is a shortcut that runs the config with `captureDenials` in `"allow"` mode and writes the report to a temp @@ -538,6 +544,76 @@ out-of-policy `cwd` makes callers that resolve relative paths (`git`, Python's fail with `Operation not permitted`. Grant the working directory in `readwritePaths` or `readonlyPaths`. +## Custom Mach, IOKit, and shared memory lists + +These `1.1.0-alpha` settings let you grant only the Mach services, IOKit +classes, and POSIX shared memory regions an app needs. + +```json +"seatbelt": { + "customMachLookups": { + "default": "deny", + "allow": ["com.apple.CARenderServer", "com.apple.fonts*"], + "deny": [] + }, + "customMachRegisters": { + "global": { "allow": ["com.example.MyApp.*"] }, + "local": { "default": "allow" } + }, + "customIoKits": { + "allow": ["AGXDeviceUserClient", "IOSurfaceRootUserClient"] + }, + "customPosixShm": { + "read": ["apple.shm.notification_center"], + "own": ["/com.example.MyApp.*"] + } +} +``` + +| Field | Meaning | +|---|---| +| `default` | `"deny"` (default) grants only the `allow` list. `"allow"` grants every name except the `deny` list; avoid it unless absolutely necessary. | +| `allow` | Names to grant. An entry ending in `*` matches every name with that prefix. A bare `*` is rejected; use `default: "allow"`. | +| `deny` | Names to block. Deny entries win over `allow`, over `default: "allow"`, and over MXC's built-in grants. | + +`customMachRegisters` has two optional blocks. `global` covers names published +through launchd for other processes to look up. `local` covers names visible +only inside the process; denial reports show them as `(per-pid)`. +`customIoKits` entries must be class names (letters, digits, and `_`). + +`customPosixShm` replaces `allow` with three lists: + +| List | Grants | +|---|---| +| `read` | Open read-only. | +| `readWrite` | Open read-write. Opening with `O_CREAT` needs `own`, even if the region exists. | +| `own` | Read, write, create, and delete. | + +Its `default: "allow"` grants read, write, and create for every name, but not +delete. Write each name exactly as the app passes it to `shm_open`, including +any leading `/`. Names are limited to 31 bytes. + +MXC rejects a config when: + +- a custom block is combined with `profileOverride`; +- `customMachLookups` is combined with `extraMachLookups`; +- a `customMachLookups` entry starts with `com.microsoft.mxc.run.`, which MXC + reserves for [process cleanup](#process-cleanup); +- an `allow` entry names only something an explicit setting denies (for + example `com.apple.SecurityServer` without `keychainAccess`, or + `IOHIDLibUserClient` with `ui.injection: false`). Change that setting instead. + This check also applies to `extraMachLookups`; +- a `deny` entry would block something an explicit setting grants (for example + the pasteboard with `ui.clipboard` enabled). + +To find the names an app needs, run it once with +[`captureDenials`](#learning-mode-capturedenials) in `"allow"` mode. In that +mode every custom list acts as `default: "allow"`, so the app isn't blocked, +while `deny` entries and setting denies still apply. The report lists each +name the lists don't grant, such as `mach-lookup:com.apple.CARenderServer` or +`ipc-posix-shm-read-data:apple.shm.notification_center`. Add those names to the +matching list. + ## Process cleanup A run ends when its main process (the `/bin/sh` that runs your command) exits, @@ -571,8 +647,9 @@ profile too. `--debug` prints these rules after the end of the generated profile, not inside it, so the profile can be copied into `profileOverride` without them. MXC -rejects a `profileOverride` or `extraMachLookups` entry that uses the -`com.microsoft.mxc.run.` prefix, because the rules MXC adds would override it. +rejects a `profileOverride`, `extraMachLookups`, or `customMachLookups` entry +that uses the `com.microsoft.mxc.run.` prefix, because the rules MXC adds would +override it. MXC freezes every matching process before killing any, so a process cannot restart one that was just killed. It scans again until two scans in a row find diff --git a/docs/containment-policy/dev/policy.md b/docs/containment-policy/dev/policy.md index d201561fcf..e3e960cef1 100644 --- a/docs/containment-policy/dev/policy.md +++ b/docs/containment-policy/dev/policy.md @@ -140,7 +140,7 @@ experimental authorization and suitable host/build support. | `processContainer` | `leastPrivilege` (boolean), `capabilities` (array of single capability names, no commas), `learningMode` (boolean), `captureDenials` (`mode`: `block` or `allow`, default `block`; optional `outputPath` and `retainEtl`), `filesystem.enumeratePaths` (array of paths), `network.allowedProxyPeer` (proxy peer identity), and `ui` (below). `enumeratePaths` needs host support and cannot combine with `leastPrivilege`. `captureDenials.mode: "allow"` permits and records otherwise denied access. | | `processContainer.ui` | Optional `isolation` (`container`, `desktop`, `handles`, `atoms`), `desktopSystemControl` and `ime` booleans, and `systemSettings` (`none`, `parameters`, `display`, `all`). | | `lxc` | `distribution` and `release` strings, both required if this section is present. | -| `seatbelt` | Optional `profileOverride` (raw profile), `guiAccess`, `nestedPty`, `keychainAccess` (booleans), `extraMachLookups` (string array), and `captureDenials` (`mode`: `block` or `allow`, default `block`; optional `outputPath` and `retainEtl`). `captureDenials` cannot combine with `profileOverride`. | +| `seatbelt` | Optional `profileOverride` (raw profile), `guiAccess`, `nestedPty`, `keychainAccess` (booleans), `extraMachLookups` (string array, deprecated), `customMachLookups` and `customIoKits` (objects with optional `default`: `deny` or `allow`, default `deny`; `allow` and `deny` string arrays), `customMachRegisters` (optional `global` and `local` objects of the same shape), `customPosixShm` (optional `default`; `read`, `readWrite`, `own`, and `deny` string arrays), and `captureDenials` (`mode`: `block` or `allow`, default `block`; optional `outputPath` and `retainEtl`). `captureDenials` and the `custom*` objects cannot combine with `profileOverride`. | | `wslc` | Optional `image`, `imageTarPath`, `storagePath`, `targetOs` (strings), `cpuCount` and `memoryMb` (non-negative integers), `gpu` (boolean), and `portMappings` (array of `windowsPort` / `containerPort`, 1-65535, optional `protocol`: TCP only). These are create-and-run settings. The contract accepts any `targetOs` string, but the WSLc backend rejects values other than `"linux"`; omit it or use `"linux"`. | | `windowsSandbox` | Optional `idleTimeoutMs` (non-negative integer milliseconds), legacy `idleTimeout` (non-negative integer), and `daemonPipeName` (string). Compatibility fields, not live controls of the current execution path. | | `hyperlight` | Optional `runtime`: `agent` (default), `python`, `python-shell`, `node`, `bash`, or `dotnet-jit`. The command line supplies source for that guest runtime. | diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md index 357adea7a9..6d8a5004fd 100644 --- a/docs/development/architecture/versioning.md +++ b/docs/development/architecture/versioning.md @@ -308,8 +308,9 @@ The v1.0 contract preserves the v0.9 request roots and canonical field/value spellings, but removes the legacy `appcontainer`, `appContainer`, and `macos_sandbox` aliases. They remain rejected throughout the v1 contract line. Features that exist only in mutable v1.1 development, including Windows Sandbox -provision, Seatbelt `captureDenials`, and the `vm`, `microvm`, and `hyperlight` -one-shot surfaces, are not accepted by v1.0. +provision, Seatbelt `captureDenials`, `customMachLookups`, +`customMachRegisters`, `customIoKits`, and `customPosixShm`, and the `vm`, +`microvm`, and `hyperlight` one-shot surfaces, are not accepted by v1.0. ### Trust boundary vs schema defaults @@ -356,6 +357,24 @@ field or set it to `"linux"`. This fail-closed exception applies within V1 and does not require a new versioned API surface. +### Servicing exception: Seatbelt settings take precedence over `guiAccess` + +In every contract version, explicit Seatbelt settings now take precedence over +`seatbelt.guiAccess`: + +- `nestedPty: false` blocks PTY creation. +- `keychainAccess: false` blocks the Keychain servers. +- `readonlyPaths` and `deniedPaths` entries inside `/private/tmp` and + `/private/var/folders` apply. + +The Seatbelt backend also rejects, with `policy_validation`, an +`extraMachLookups` entry that names only a service an explicit setting denies, +such as `com.apple.SecurityServer` without `keychainAccess: true`. Change that +setting instead. + +This exception applies within V1 and does not require a new versioned API +surface. + ### Shipped vs Experimental Development features use their intended permanent top-level locations in the diff --git a/docs/schema.md b/docs/schema.md index 63bf568f44..c1e25acb4b 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -274,10 +274,45 @@ that can be executed independently. "seatbelt": { // macOS Seatbelt settings (macOS only) "profileOverride": null, // Optional raw TinyScheme profile (escape hatch) - "guiAccess": false, // Allow GUI Mach services / IOKit / pty for window-drawing apps + "guiAccess": false, // Allow GUI Mach services / IOKit for window-drawing apps "nestedPty": true, // Allow inner process to allocate its own pty (posix_openpt) "keychainAccess": false, // Allow Keychain via securityd / trustd / cfprefsd / lsd.* - "extraMachLookups": [], // Additional Mach service global-names the inner process may resolve + "extraMachLookups": [], // Additional Mach service global-names the inner process may resolve. + // Deprecated in 1.1.0-alpha: use customMachLookups.allow + "customMachLookups": { // 1.1.0-alpha: Mach services the process may look up. + // Replaces guiAccess's lookup grant when present. + "default": "deny", // "deny" (default) or "allow" (grant all except deny) + "allow": ["com.apple.fonts*"], // Exact names; a trailing * matches a prefix + "deny": [] // Always blocked, even with default "allow" + }, + "customMachRegisters": { // 1.1.0-alpha: Mach services the process may register. + // Replaces guiAccess's register grant per block present. + "global": { // Names published through launchd + "default": "deny", // "deny" (default) or "allow" (grant all except deny) + "allow": ["com.example.MyApp.*"], // Exact names; a trailing * matches a prefix + "deny": [] // Always blocked, even with default "allow" + }, + "local": { // Names visible only inside the process + "default": "deny", + "allow": [], + "deny": [] + } + }, + "customIoKits": { // 1.1.0-alpha: IOKit user-client classes the process may open. + // Replaces guiAccess's IOKit grant when present. + "default": "deny", // "deny" (default) or "allow" (grant all except deny) + "allow": ["IOSurfaceRootUserClient"], // Class names; a trailing * matches a prefix + "deny": [] // Always blocked, even with default "allow" + }, + "customPosixShm": { // 1.1.0-alpha: POSIX shared memory names (max 31 bytes). + // Replaces guiAccess's shared memory grant when present. + "default": "deny", // "deny" (default) or "allow" (read, write, and create + // all except deny; never delete) + "read": [], // Open read-only. A trailing * matches a prefix + "readWrite": [], // Open read-write + "own": ["/com.example.MyApp.*"], // Read, write, create, and delete + "deny": [] // Always blocked, even with default "allow" + }, "captureDenials": { // 1.1.0-alpha: record what the sandbox blocked. // Not combinable with profileOverride. "mode": "block", // "block" (default) or "allow" (default-denied access is diff --git a/schemas/dev/mxc-config.schema.1.1.0-alpha.json b/schemas/dev/mxc-config.schema.1.1.0-alpha.json index 22058b7125..b655388b63 100644 --- a/schemas/dev/mxc-config.schema.1.1.0-alpha.json +++ b/schemas/dev/mxc-config.schema.1.1.0-alpha.json @@ -1437,8 +1437,24 @@ "$ref": "#/definitions/SeatbeltCaptureDenials", "description": "Records sandbox denials to a JSON report." }, + "customIoKits": { + "$ref": "#/definitions/SeatbeltCustomRules", + "description": "IOKit user-client classes the process may open. When present, this replaces guiAccess's IOKit grant." + }, + "customMachLookups": { + "$ref": "#/definitions/SeatbeltCustomRules", + "description": "Mach service global names the process may resolve. When present, this replaces guiAccess's Mach lookup grant." + }, + "customMachRegisters": { + "$ref": "#/definitions/SeatbeltCustomMachRegisters", + "description": "Mach service names the process may register. Each present block replaces guiAccess's registration grant for its names." + }, + "customPosixShm": { + "$ref": "#/definitions/SeatbeltCustomPosixShm", + "description": "POSIX shared memory names the process may use. When present, this replaces guiAccess's shared memory grant." + }, "extraMachLookups": { - "description": "Additional Mach service global names the process may resolve.", + "description": "Additional Mach service global names the process may resolve. Deprecated: use `customMachLookups.allow`.", "items": { "type": "string" }, @@ -1482,6 +1498,103 @@ }, "type": "object" }, + "SeatbeltCustomDefault": { + "oneOf": [ + { + "description": "Allow", + "enum": [ + "allow" + ], + "type": "string" + }, + { + "description": "Deny", + "enum": [ + "deny" + ], + "type": "string" + } + ] + }, + "SeatbeltCustomMachRegisters": { + "additionalProperties": false, + "description": "Mach service registration rules, by name scope.", + "properties": { + "global": { + "$ref": "#/definitions/SeatbeltCustomRules", + "description": "Names registered with launchd, visible to other processes." + }, + "local": { + "$ref": "#/definitions/SeatbeltCustomRules", + "description": "Per-process names, shown as `(per-pid)` in denial reports." + } + }, + "type": "object" + }, + "SeatbeltCustomPosixShm": { + "additionalProperties": false, + "description": "POSIX shared memory rules, by access. An entry ending in `*` matches every name with that prefix. `deny` entries win over every other list.", + "properties": { + "default": { + "$ref": "#/definitions/SeatbeltCustomDefault", + "description": "Access for unlisted names. Defaults to `deny`. `allow` grants read, write, and create." + }, + "deny": { + "description": "Names to deny.", + "items": { + "type": "string" + }, + "type": "array" + }, + "own": { + "description": "Names the process may read, write, create, and delete.", + "items": { + "type": "string" + }, + "type": "array" + }, + "read": { + "description": "Names the process may read.", + "items": { + "type": "string" + }, + "type": "array" + }, + "readWrite": { + "description": "Existing names the process may read and write.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "SeatbeltCustomRules": { + "additionalProperties": false, + "description": "Allow and deny lists for one kind of Seatbelt name. An entry ending in `*` matches every name with that prefix. `deny` entries win over `allow`.", + "properties": { + "allow": { + "description": "Names to allow.", + "items": { + "type": "string" + }, + "type": "array" + }, + "default": { + "$ref": "#/definitions/SeatbeltCustomDefault", + "description": "Access for unlisted names. Defaults to `deny`." + }, + "deny": { + "description": "Names to deny, including names MXC allows by default.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "StartPhase": { "enum": [ "start" diff --git a/sdk/node/src/generated/v1_1_0_alpha/wire.ts b/sdk/node/src/generated/v1_1_0_alpha/wire.ts index 0b56b57949..aa371e6560 100644 --- a/sdk/node/src/generated/v1_1_0_alpha/wire.ts +++ b/sdk/node/src/generated/v1_1_0_alpha/wire.ts @@ -711,7 +711,23 @@ export interface Seatbelt { */ captureDenials?: SeatbeltCaptureDenials; /** - * Additional Mach service global names the process may resolve. + * IOKit user-client classes the process may open. When present, this replaces guiAccess's IOKit grant. + */ + customIoKits?: SeatbeltCustomRules; + /** + * Mach service global names the process may resolve. When present, this replaces guiAccess's Mach lookup grant. + */ + customMachLookups?: SeatbeltCustomRules; + /** + * Mach service names the process may register. Each present block replaces guiAccess's registration grant for its names. + */ + customMachRegisters?: SeatbeltCustomMachRegisters; + /** + * POSIX shared memory names the process may use. When present, this replaces guiAccess's shared memory grant. + */ + customPosixShm?: SeatbeltCustomPosixShm; + /** + * Additional Mach service global names the process may resolve. Deprecated: use `customMachLookups.allow`. */ extraMachLookups?: string[]; /** @@ -750,6 +766,66 @@ export interface SeatbeltCaptureDenials { retainEtl?: boolean; } +export type SeatbeltCustomDefault = "allow" | "deny"; + +/** + * Mach service registration rules, by name scope. + */ +export interface SeatbeltCustomMachRegisters { + /** + * Names registered with launchd, visible to other processes. + */ + global?: SeatbeltCustomRules; + /** + * Per-process names, shown as `(per-pid)` in denial reports. + */ + local?: SeatbeltCustomRules; +} + +/** + * POSIX shared memory rules, by access. An entry ending in `*` matches every name with that prefix. `deny` entries win over every other list. + */ +export interface SeatbeltCustomPosixShm { + /** + * Access for unlisted names. Defaults to `deny`. `allow` grants read, write, and create. + */ + default?: SeatbeltCustomDefault; + /** + * Names to deny. + */ + deny?: string[]; + /** + * Names the process may read, write, create, and delete. + */ + own?: string[]; + /** + * Names the process may read. + */ + read?: string[]; + /** + * Existing names the process may read and write. + */ + readWrite?: string[]; +} + +/** + * Allow and deny lists for one kind of Seatbelt name. An entry ending in `*` matches every name with that prefix. `deny` entries win over `allow`. + */ +export interface SeatbeltCustomRules { + /** + * Names to allow. + */ + allow?: string[]; + /** + * Access for unlisted names. Defaults to `deny`. + */ + default?: SeatbeltCustomDefault; + /** + * Names to deny, including names MXC allows by default. + */ + deny?: string[]; +} + export type StartPhase = "start"; /** diff --git a/src/mxc-sdk/src/backends/seatbelt/common/capture_analysis.rs b/src/mxc-sdk/src/backends/seatbelt/common/capture_analysis.rs index 0939ca2d01..1fff7c610f 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/capture_analysis.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/capture_analysis.rs @@ -18,6 +18,7 @@ use crate::learning_mode_core::{ MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, }; use crate::seatbelt_common::profile_builder::GRANT_TAG_SUFFIX; +use crate::seatbelt_common::seatbelt_policy::PASTEBOARD_SERVICE_PREFIX; /// Same bound as the Windows decoder. const MAX_UNIQUE_DENIALS: usize = 10_000; @@ -205,7 +206,7 @@ fn is_ui_resource(operation: &str, target: &str) -> bool { match operation { "hid-control" => true, "mach-lookup" => { - target.starts_with("com.apple.pasteboard.") + target.starts_with(PASTEBOARD_SERVICE_PREFIX) || target.starts_with("com.apple.windowserver.") || target == "com.apple.coreservices.launchservicesd" } diff --git a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs index d25eeeff5c..5056724636 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs @@ -34,8 +34,9 @@ use crate::mxc_common::filesystem_resolve::{resolve_path_plan, FsIntent}; use crate::mxc_common::host_is_canonical_loopback; use crate::mxc_common::models::{ CaptureDenialsMode, ClipboardPolicy, ContainerPolicy, ExecutionRequest, ProxyAddress, + SeatbeltCustomDefault, SeatbeltCustomRules, }; -use crate::seatbelt_common::seatbelt_policy; +use crate::seatbelt_common::seatbelt_policy::{self, NamePattern}; use std::fmt::Write as _; use std::path::PathBuf; @@ -176,6 +177,11 @@ pub(crate) fn build_profile_with_terminals( ); } + // Option filesystem grants come before the filesystem policy so that + // readonlyPaths and deniedPaths entries inside them still apply. + write_keychain_rules(&mut out, request, inputs.user_keychains.as_deref())?; + write_gui_filesystem_rules(&mut out, request); + // Policy-derived allow rules. let resolved = ResolvedPaths::from_inputs(inputs); write_filesystem_allow(&mut out, &resolved); @@ -186,9 +192,14 @@ pub(crate) fn build_profile_with_terminals( capture.as_ref().is_some_and(|rules| rules.allow_mode), ); write_nested_pty_rules(&mut out, request); - write_keychain_rules(&mut out, request, inputs.user_keychains.as_deref())?; - write_extra_seatbelt_rules(&mut out, request); write_ui_rules(&mut out, request, capture.as_ref()); + write_extra_seatbelt_rules(&mut out, request); + write_custom_allows(&mut out, request, capture.as_ref()); + + // Denies from explicit settings follow every allow so no grant reopens them. + write_ui_policy_denies(&mut out, request); + write_keychain_denies(&mut out, request); + write_custom_denies(&mut out, request); // Policy-derived deny rules go LAST so they win on conflict. write_filesystem_deny(&mut out, &resolved); @@ -525,10 +536,7 @@ fn write_ui_rules(out: &mut String, request: &ExecutionRequest, capture: Option< // disabled we omit those allows (and add explicit denies for clarity). if !ui.disable { out.push_str(";; --- ui enabled: allow WindowServer + LaunchServices ---\n"); - out.push_str("(allow mach-lookup\n"); - out.push_str(" (global-name \"com.apple.windowserver.active\")\n"); - out.push_str(" (global-name \"com.apple.windowserver.session\")\n"); - out.push_str(" (global-name \"com.apple.coreservices.launchservicesd\"))\n"); + write_global_names(out, "allow", seatbelt_policy::UI_SERVICE_NAMES); if gui_access { // GUI apps need a broad set of Mach services to draw windows — @@ -541,68 +549,106 @@ fn write_ui_rules(out: &mut String, request: &ExecutionRequest, capture: Option< let report = capture .map(CaptureRules::grant_modifier) .unwrap_or_default(); - out.push_str(";; --- guiAccess: allow all Mach IPC for GUI applications ---\n"); - let _ = writeln!(out, "(allow mach-lookup{report})"); + // Each custom block that is present replaces the grant for its names. + let seatbelt = request.seatbelt.as_ref(); + let registers = seatbelt.and_then(|c| c.custom_mach_registers.as_ref()); + let custom_global = registers.is_some_and(|r| r.global.is_some()); + let custom_local = registers.is_some_and(|r| r.local.is_some()); + if seatbelt.is_none_or(|c| c.custom_mach_lookups.is_none()) { + out.push_str(";; --- guiAccess: allow all Mach lookups for GUI applications ---\n"); + let _ = writeln!(out, "(allow mach-lookup{report})"); + } // GUI apps must register their own Mach services (XPC listeners) // to receive callbacks from WindowServer and other system agents. - let _ = writeln!(out, "(allow mach-register{report})"); + let register_scope = match (custom_global, custom_local) { + (false, false) => Some(""), + (false, true) => Some(" (global-name-prefix \"\")"), + (true, false) => Some(" (local-name-prefix \"\")"), + (true, true) => None, + }; + if let Some(scope) = register_scope { + out.push_str( + ";; --- guiAccess: allow Mach registration for GUI applications ---\n", + ); + let _ = writeln!(out, "(allow mach-register{report}{scope})"); + } // IOKit user-client access for GPU / Metal rendering - out.push_str(";; --- guiAccess: allow IOKit for GPU rendering ---\n"); - let _ = writeln!(out, "(allow iokit-open{report})"); - - // Needed for app temp files, caches, GPU shader caches - out.push_str(";; --- guiAccess: allow writing to per-user temp/cache ---\n"); - out.push_str("(allow file-read* file-write*\n"); - out.push_str(" (subpath \"/private/tmp\")\n"); - out.push_str(" (subpath \"/private/var/folders\"))\n"); - - // Pseudo-TTY support — Terminal.app and other GUI apps that - // spawn shell sessions need to open, grant, and use PTY devices. - out.push_str(";; --- guiAccess: allow pseudo-TTY for shell sessions ---\n"); - out.push_str("(allow pseudo-tty)\n"); - out.push_str("(allow file-read* file-write* file-ioctl\n"); - out.push_str(" (literal \"/dev/ptmx\"))\n"); - - // POSIX shared memory and IPC — required by Terminal.app and - // other apps that use notification center or shared memory. - out.push_str(";; --- guiAccess: allow POSIX IPC for GUI apps ---\n"); - out.push_str("(allow ipc-posix-shm-read-data ipc-posix-shm-write-data ipc-posix-shm-write-create)\n"); + if seatbelt.is_none_or(|c| c.custom_io_kits.is_none()) { + out.push_str(";; --- guiAccess: allow IOKit for GPU rendering ---\n"); + let _ = writeln!(out, "(allow iokit-open{report})"); + } + + if seatbelt.is_none_or(|c| c.custom_posix_shm.is_none()) { + out.push_str(";; --- guiAccess: allow POSIX shared memory ---\n"); + let _ = writeln!(out, "(allow {GUI_POSIX_SHM_OPERATIONS}{report})"); + } } - } else { - out.push_str(";; --- ui.disable: deny WindowServer + related ---\n"); - out.push_str("(deny mach-lookup\n"); - out.push_str(" (global-name \"com.apple.windowserver.active\")\n"); - out.push_str(" (global-name \"com.apple.windowserver.session\")\n"); - out.push_str(" (global-name \"com.apple.coreservices.launchservicesd\"))\n"); } - // Clipboard: allow pasteboard mach service when clipboard is read, - // write, or all. The explicit deny when clipboard=none is redundant - // with `(deny default)` but documents intent. - let clipboard_allowed = !matches!(ui.clipboard, ClipboardPolicy::None); - if clipboard_allowed { + if !matches!(ui.clipboard, ClipboardPolicy::None) { out.push_str(";; --- clipboard enabled: allow pasteboard ---\n"); - out.push_str("(allow mach-lookup (global-name \"com.apple.pasteboard.1\"))\n"); - } else { - out.push_str(";; --- ui.clipboard=none: deny pasteboard ---\n"); - out.push_str("(deny mach-lookup (global-name \"com.apple.pasteboard.1\"))\n"); + let _ = writeln!( + out, + "(allow mach-lookup (global-name {}))", + quote_scheme(seatbelt_policy::PASTEBOARD_SERVICE_NAME) + ); + } +} + +/// Per-user temp and cache directories that GUI apps write to (app temp +/// files, caches, GPU shader caches). +fn write_gui_filesystem_rules(out: &mut String, request: &ExecutionRequest) { + if !seatbelt_policy::gui_access_effective(request) { + return; + } + out.push_str(";; --- guiAccess: allow writing to per-user temp/cache ---\n"); + out.push_str("(allow file-read* file-write*\n"); + out.push_str(" (subpath \"/private/tmp\")\n"); + out.push_str(" (subpath \"/private/var/folders\"))\n"); +} + +/// Emit one `( mach-lookup (global-name …)…)` rule. +fn write_global_names(out: &mut String, action: &str, names: &[&str]) { + let _ = writeln!(out, "({action} mach-lookup"); + for name in names { + let _ = writeln!(out, " (global-name {})", quote_scheme(name)); + } + out.push_str(")\n"); +} + +/// UI settings that remove access. Emitted after every allow rule. +fn write_ui_policy_denies(out: &mut String, request: &ExecutionRequest) { + let ui = &request.policy.ui; + if ui.disable { + out.push_str(";; --- ui.disable: deny WindowServer + related ---\n"); + write_global_names(out, "deny", seatbelt_policy::UI_SERVICE_NAMES); + } + + if matches!(ui.clipboard, ClipboardPolicy::None) { + out.push_str(";; --- ui.clipboard=none: deny pasteboard servers ---\n"); + let _ = writeln!( + out, + "(deny mach-lookup (global-name-prefix {}))", + quote_scheme(seatbelt_policy::PASTEBOARD_SERVICE_PREFIX) + ); } if !ui.injection { out.push_str(";; --- ui.injection=false: deny HID iokit access ---\n"); - out.push_str("(deny iokit-open (iokit-user-client-class \"IOHIDLibUserClient\"))\n"); + let _ = writeln!( + out, + "(deny iokit-open (iokit-user-client-class {}))", + quote_scheme(seatbelt_policy::HID_USER_CLIENT_CLASS) + ); } } /// Emit rules so the inner process can call `posix_openpt()` and allocate -/// its own pty. Skipped when `gui_access` (with UI enabled) already emits -/// a strict superset. +/// its own pty. fn write_nested_pty_rules(out: &mut String, request: &ExecutionRequest) { - let sb = request.seatbelt.as_ref(); - let enabled = sb.is_none_or(|c| c.nested_pty); - let gui_block_emitted = seatbelt_policy::gui_access_effective(request); - if !enabled || gui_block_emitted { + let enabled = request.seatbelt.as_ref().is_none_or(|c| c.nested_pty); + if !enabled { return; } out.push_str(";; --- nestedPty: allow inner process to allocate its own pty ---\n"); @@ -652,8 +698,9 @@ fn write_keychain_rules( out.push_str(";; --- keychainAccess: Mach IPC for Keychain (securityd, prefs, XPC, LS) ---\n"); out.push_str("(allow mach-lookup\n"); - out.push_str(" (global-name \"com.apple.SecurityServer\")\n"); - out.push_str(" (global-name \"com.apple.securityd\")\n"); + for name in seatbelt_policy::KEYCHAIN_GRANTED_NAMES { + let _ = writeln!(out, " (global-name {})", quote_scheme(name)); + } // trustd handles SecTrustSettingsCopyTrustSettings; without it Security // logs "failed to copy trust settings of system certificate-N" for every // cert in the system root store on every TLS handshake. @@ -681,6 +728,23 @@ fn write_keychain_rules( Ok(()) } +/// Without `keychainAccess`, deny the Keychain servers after every allow so +/// that no broader Mach grant reaches them. +fn write_keychain_denies(out: &mut String, request: &ExecutionRequest) { + if request.seatbelt.as_ref().is_some_and(|c| c.keychain_access) { + return; + } + out.push_str(";; --- keychainAccess=false: deny Keychain servers ---\n"); + out.push_str("(deny mach-lookup\n"); + for name in seatbelt_policy::KEYCHAIN_SERVER_NAMES { + let _ = writeln!(out, " (global-name {})", quote_scheme(name)); + } + for prefix in seatbelt_policy::KEYCHAIN_SERVER_PREFIXES { + let _ = writeln!(out, " (global-name-prefix {})", quote_scheme(prefix)); + } + out.push_str(")\n"); +} + /// Emit caller-provided `extraMachLookups` rules: additional Mach service /// global-names the inner process may resolve. No-op when the list is empty. fn write_extra_seatbelt_rules(out: &mut String, request: &ExecutionRequest) { @@ -699,6 +763,184 @@ fn write_extra_seatbelt_rules(out: &mut String, request: &ExecutionRequest) { out.push_str(")\n"); } +/// The Seatbelt operation and name filters for one custom rule list. +struct CustomRuleTarget { + field: &'static str, + operation: &'static str, + /// Filter for every name in scope; empty for the whole operation. + scope: &'static str, + exact: &'static str, + prefix: &'static str, +} + +const IOKIT_TARGET: CustomRuleTarget = CustomRuleTarget { + field: "customIoKits", + operation: "iokit-open", + scope: "", + exact: "iokit-user-client-class", + prefix: "iokit-user-client-class-regex", +}; +const LOOKUP_TARGET: CustomRuleTarget = CustomRuleTarget { + field: "customMachLookups", + operation: "mach-lookup", + scope: "", + exact: "global-name", + prefix: "global-name-prefix", +}; +const GLOBAL_REGISTER_TARGET: CustomRuleTarget = CustomRuleTarget { + field: "customMachRegisters.global", + operation: "mach-register", + scope: " (global-name-prefix \"\")", + exact: "global-name", + prefix: "global-name-prefix", +}; +const LOCAL_REGISTER_TARGET: CustomRuleTarget = CustomRuleTarget { + field: "customMachRegisters.local", + operation: "mach-register", + scope: " (local-name-prefix \"\")", + exact: "local-name", + prefix: "local-name-prefix", +}; + +/// guiAccess's shared memory grant, and `customPosixShm` `default: "allow"`. +const GUI_POSIX_SHM_OPERATIONS: &str = + "ipc-posix-shm-read-data ipc-posix-shm-write-data ipc-posix-shm-write-create"; + +const fn posix_shm_target(field: &'static str, operation: &'static str) -> CustomRuleTarget { + CustomRuleTarget { + field, + operation, + scope: "", + exact: "ipc-posix-name", + prefix: "ipc-posix-name-prefix", + } +} + +const SHM_READ_TARGET: CustomRuleTarget = + posix_shm_target("customPosixShm.read", "ipc-posix-shm-read*"); +const SHM_READ_WRITE_TARGET: CustomRuleTarget = posix_shm_target( + "customPosixShm.readWrite", + "ipc-posix-shm-read* ipc-posix-shm-write-data", +); +// Also covers create and delete. +const SHM_OWN_TARGET: CustomRuleTarget = posix_shm_target("customPosixShm.own", "ipc-posix-shm*"); +const SHM_DENY_TARGET: CustomRuleTarget = posix_shm_target("customPosixShm.deny", "ipc-posix-shm*"); + +fn custom_rule_targets( + request: &ExecutionRequest, +) -> Vec<(&CustomRuleTarget, &SeatbeltCustomRules)> { + let Some(config) = request.seatbelt.as_ref() else { + return Vec::new(); + }; + let registers = config.custom_mach_registers.as_ref(); + [ + (&IOKIT_TARGET, config.custom_io_kits.as_ref()), + (&LOOKUP_TARGET, config.custom_mach_lookups.as_ref()), + ( + &GLOBAL_REGISTER_TARGET, + registers.and_then(|r| r.global.as_ref()), + ), + ( + &LOCAL_REGISTER_TARGET, + registers.and_then(|r| r.local.as_ref()), + ), + ] + .into_iter() + .filter_map(|(target, rules)| rules.map(|rules| (target, rules))) + .collect() +} + +/// Emit one `( …)` rule for custom list entries. +fn write_custom_rule( + out: &mut String, + action: &str, + target: &CustomRuleTarget, + entries: &[String], +) { + let _ = writeln!(out, "({action} {}", target.operation); + for entry in entries { + let _ = match NamePattern::parse(entry) { + NamePattern::Exact(name) => { + writeln!(out, " ({} {})", target.exact, quote_scheme(name)) + } + // Validation limits IOKit class names to letters, digits, and '_'. + NamePattern::Prefix(prefix) if target.prefix.ends_with("-regex") => { + writeln!(out, " ({} #\"^{prefix}\")", target.prefix) + } + NamePattern::Prefix(prefix) => { + writeln!(out, " ({} {})", target.prefix, quote_scheme(prefix)) + } + }; + } + out.push_str(")\n"); +} + +/// Emit custom `default: "allow"` grants and `allow` lists. Under +/// captureDenials, a default grant reports each use like guiAccess's grants. +fn write_custom_allows( + out: &mut String, + request: &ExecutionRequest, + capture: Option<&CaptureRules>, +) { + for (target, rules) in custom_rule_targets(request) { + if rules.default == SeatbeltCustomDefault::Allow { + let report = capture + .map(CaptureRules::grant_modifier) + .unwrap_or_default(); + let _ = writeln!(out, ";; --- {}: default allow ---", target.field); + let _ = writeln!(out, "(allow {}{report}{})", target.operation, target.scope); + } + if !rules.allow.is_empty() { + let _ = writeln!(out, ";; --- {}.allow ---", target.field); + write_custom_rule(out, "allow", target, &rules.allow); + } + } + let Some(shm) = request + .seatbelt + .as_ref() + .and_then(|c| c.custom_posix_shm.as_ref()) + else { + return; + }; + if shm.default == SeatbeltCustomDefault::Allow { + let report = capture + .map(CaptureRules::grant_modifier) + .unwrap_or_default(); + out.push_str(";; --- customPosixShm: default allow ---\n"); + let _ = writeln!(out, "(allow {GUI_POSIX_SHM_OPERATIONS}{report})"); + } + for (target, entries) in [ + (&SHM_READ_TARGET, &shm.read), + (&SHM_READ_WRITE_TARGET, &shm.read_write), + (&SHM_OWN_TARGET, &shm.own), + ] { + if !entries.is_empty() { + let _ = writeln!(out, ";; --- {} ---", target.field); + write_custom_rule(out, "allow", target, entries); + } + } +} + +/// Emit custom `deny` lists after every allow, so they also remove MXC's +/// built-in grants. +fn write_custom_denies(out: &mut String, request: &ExecutionRequest) { + for (target, rules) in custom_rule_targets(request) { + if !rules.deny.is_empty() { + let _ = writeln!(out, ";; --- {}.deny ---", target.field); + write_custom_rule(out, "deny", target, &rules.deny); + } + } + if let Some(shm) = request + .seatbelt + .as_ref() + .and_then(|c| c.custom_posix_shm.as_ref()) + .filter(|shm| !shm.deny.is_empty()) + { + let _ = writeln!(out, ";; --- {} ---", SHM_DENY_TARGET.field); + write_custom_rule(out, "deny", &SHM_DENY_TARGET, &shm.deny); + } +} + /// Quote a string for use as a TinyScheme string literal, escaping /// embedded backslashes and double-quotes. fn quote_scheme(s: &str) -> String { @@ -724,7 +966,10 @@ fn escape_for_quotes(s: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::models::{NetworkAction, SeatbeltConfig, UiPolicy}; + use crate::mxc_common::models::{ + NetworkAction, SeatbeltConfig, SeatbeltCustomMachRegisters, SeatbeltCustomPosixShm, + UiPolicy, + }; fn build_profile(request: &ExecutionRequest) -> Result { build_profile_with_proxy(request, request.policy.network_proxy.address.as_ref()) @@ -1265,7 +1510,29 @@ mod tests { let r = req(); // Default clipboard is None. let p = build_profile(&r).unwrap(); - assert!(p.contains("com.apple.pasteboard.1")); + assert!(p.contains("(deny mach-lookup (global-name-prefix \"com.apple.pasteboard.\"))")); + assert!(!p.contains("(global-name \"com.apple.pasteboard.1\")")); + } + + /// Like the Keychain denies, the pasteboard deny follows every broader + /// Mach grant, including custom allow lists. + #[test] + fn clipboard_deny_follows_custom_mach_grants() { + let mut r = req(); + r.policy.ui.disable = false; + r.seatbelt = Some(SeatbeltConfig { + gui_access: true, + custom_mach_lookups: Some(SeatbeltCustomRules { + default: SeatbeltCustomDefault::Allow, + allow: vec!["com.apple.*".into()], + deny: Vec::new(), + }), + ..Default::default() + }); + let p = build_profile(&r).unwrap(); + let deny = p.find("ui.clipboard=none: deny").expect("pasteboard deny"); + assert!(p.find("customMachLookups: default allow").unwrap() < deny); + assert!(p.find("customMachLookups.allow").unwrap() < deny); } #[test] @@ -1618,26 +1885,24 @@ mod tests { } #[test] - fn nested_pty_skipped_when_gui_block_emitted() { - // gui_access + ui enabled emits a strict superset of nested_pty - // rules. Verify we don't double-emit. + fn gui_access_does_not_grant_pty_allocation() { let mut r = req(); r.policy.ui = UiPolicy { disable: false, clipboard: ClipboardPolicy::None, injection: true, }; - r.seatbelt = Some(SeatbeltConfig { - gui_access: true, - nested_pty: true, - ..Default::default() - }); - let p = build_profile(&r).unwrap(); - // No nestedPty comment block — gui_access block carries the rules. - assert!(!p.contains("nestedPty")); - // GUI block's broader rules should still be there. - assert!(p.contains("(allow iokit-open)")); - assert!(p.contains("(allow pseudo-tty)")); + for nested_pty in [true, false] { + r.seatbelt = Some(SeatbeltConfig { + gui_access: true, + nested_pty, + ..Default::default() + }); + let p = build_profile(&r).unwrap(); + assert!(p.contains("(allow iokit-open)")); + assert_eq!(p.contains("(allow pseudo-tty)"), nested_pty, "{p}"); + assert_eq!(p.contains("/dev/ptmx"), nested_pty, "{p}"); + } } #[test] @@ -1661,18 +1926,67 @@ mod tests { } #[test] - fn keychain_access_default_off_omits_security_services() { + fn keychain_access_default_off_denies_keychain_servers() { let r = req(); let p = build_profile(&r).unwrap(); - assert!(!p.contains("keychainAccess")); - assert!(!p.contains("com.apple.SecurityServer")); - assert!(!p.contains("com.apple.securityd")); + assert!(!p.contains(";; --- keychainAccess: ")); + assert!(p.contains( + "(deny mach-lookup\n (global-name \"com.apple.SecurityServer\")\n \ + (global-name-prefix \"com.apple.securityd\")\n)" + )); assert!(!p.contains("com.apple.cfprefsd.daemon")); assert!(!p.contains("com.apple.lsd")); assert!(!p.contains("/Library/Keychains")); assert!(!p.contains("/private/var/db/mds")); } + /// The Keychain denies follow every broader Mach grant, so neither + /// guiAccess nor extraMachLookups reaches the Keychain servers. + #[test] + fn keychain_denies_follow_broader_mach_grants() { + let mut r = req(); + r.policy.ui.disable = false; + r.seatbelt = Some(SeatbeltConfig { + gui_access: true, + extra_mach_lookups: vec!["com.apple.SecurityServer".into()], + ..Default::default() + }); + let p = build_profile(&r).unwrap(); + let deny = p.find("keychainAccess=false: deny").expect("keychain deny"); + assert!(p.find("(allow mach-lookup)").unwrap() < deny); + assert!(p.find("extraMachLookups").unwrap() < deny); + + r.seatbelt.as_mut().unwrap().keychain_access = true; + let p = build_profile(&r).unwrap(); + assert!(!p.contains("keychainAccess=false")); + } + + /// guiAccess's temp/cache grant precedes the filesystem policy, so a + /// readonlyPaths or deniedPaths entry inside it still applies. + #[test] + fn filesystem_policy_follows_option_filesystem_grants() { + let mut r = req(); + r.policy.ui.disable = false; + r.policy.readonly_paths = vec!["/private/tmp/ro".into()]; + r.policy.denied_paths = vec!["/private/var/folders/xy".into()]; + r.seatbelt = Some(SeatbeltConfig { + gui_access: true, + keychain_access: true, + ..Default::default() + }); + let p = build_profile(&r).unwrap(); + let gui = p.find("guiAccess: allow writing").unwrap(); + let keychain = p.find("keychainAccess: user keychain").unwrap(); + let readonly = p + .find("(deny file-write* network-bind network-outbound\n (subpath \"/private/tmp/ro\")") + .unwrap(); + let denied = p.find("policy.deniedPaths").unwrap(); + assert!( + gui < readonly && keychain < readonly && readonly < denied, + "{p}" + ); + } + #[test] fn keychain_access_true_allows_securityd_mach_services() { let mut r = req(); @@ -1923,7 +2237,12 @@ mod tests { fn capture_reports_broad_gui_grants_under_the_grant_tag() { let p = capture_profile(&capture_request(CaptureDenialsMode::Block, true)); let report = " (with report) (with message \"mxc-capture-test:grant\")"; - for op in ["mach-lookup", "mach-register", "iokit-open"] { + for op in [ + "mach-lookup", + "mach-register", + "iokit-open", + GUI_POSIX_SHM_OPERATIONS, + ] { assert!(p.contains(&format!("(allow {op}{report})")), "{op}: {p}"); } } @@ -1948,6 +2267,211 @@ mod tests { ); } + fn custom_rules( + default: SeatbeltCustomDefault, + allow: &[&str], + deny: &[&str], + ) -> SeatbeltCustomRules { + SeatbeltCustomRules { + default, + allow: allow.iter().map(|s| s.to_string()).collect(), + deny: deny.iter().map(|s| s.to_string()).collect(), + } + } + + /// guiAccess with every custom block set to `default: deny` and one + /// exact and one prefix entry in each list. + fn custom_request() -> ExecutionRequest { + let mut r = req(); + r.policy.ui.disable = false; + let rules = |a: &str, d: &str| { + custom_rules(SeatbeltCustomDefault::Deny, &[a, &format!("{a}.*")], &[d]) + }; + r.seatbelt = Some(SeatbeltConfig { + gui_access: true, + custom_io_kits: Some(custom_rules( + SeatbeltCustomDefault::Deny, + &["AGXDeviceUserClient", "IOSurface*"], + &["AppleUSB*"], + )), + custom_mach_lookups: Some(rules("com.example.lookup", "com.example.blocked")), + custom_mach_registers: Some(SeatbeltCustomMachRegisters { + global: Some(rules("com.example.global", "com.example.global.no")), + local: Some(rules("com.example.local", "com.example.local.no")), + }), + custom_posix_shm: Some(SeatbeltCustomPosixShm { + read: vec!["apple.shm.notification_center".into()], + read_write: vec!["/example.rw".into()], + own: vec!["/example.app.*".into()], + deny: vec!["/example.app.secret".into()], + ..Default::default() + }), + ..Default::default() + }); + r + } + + #[test] + fn custom_rules_replace_gui_access_grants() { + let p = build_profile(&custom_request()).unwrap(); + for broad in [ + "(allow mach-lookup)", + "(allow mach-register)", + "(allow mach-register (", + "(allow iokit-open)", + "(allow ipc-posix-shm-read-data", + ] { + assert!(!p.contains(broad), "{broad}: {p}"); + } + for rule in [ + "(allow iokit-open\n (iokit-user-client-class \"AGXDeviceUserClient\")\n \ + (iokit-user-client-class-regex #\"^IOSurface\")\n)", + "(allow mach-lookup\n (global-name \"com.example.lookup\")\n \ + (global-name-prefix \"com.example.lookup.\")\n)", + "(allow mach-register\n (global-name \"com.example.global\")\n \ + (global-name-prefix \"com.example.global.\")\n)", + "(allow mach-register\n (local-name \"com.example.local\")\n \ + (local-name-prefix \"com.example.local.\")\n)", + "(deny iokit-open\n (iokit-user-client-class-regex #\"^AppleUSB\")\n)", + "(deny mach-lookup\n (global-name \"com.example.blocked\")\n)", + "(deny mach-register\n (local-name \"com.example.local.no\")\n)", + "(allow ipc-posix-shm-read*\n (ipc-posix-name \"apple.shm.notification_center\")\n)", + "(allow ipc-posix-shm-read* ipc-posix-shm-write-data\n \ + (ipc-posix-name \"/example.rw\")\n)", + "(allow ipc-posix-shm*\n (ipc-posix-name-prefix \"/example.app.\")\n)", + "(deny ipc-posix-shm*\n (ipc-posix-name \"/example.app.secret\")\n)", + ] { + assert!(p.contains(rule), "{rule}: {p}"); + } + // guiAccess's filesystem grant remains. + assert!(p.contains("(subpath \"/private/tmp\")")); + } + + /// Each block replaces only its own grant; register scopes are separate. + #[test] + fn omitted_custom_blocks_keep_gui_access_grants() { + let mut r = custom_request(); + let config = r.seatbelt.as_mut().unwrap(); + config.custom_io_kits = None; + config.custom_mach_lookups = None; + config.custom_posix_shm = None; + config.custom_mach_registers.as_mut().unwrap().global = None; + let p = build_profile(&r).unwrap(); + assert!(p.contains("(allow iokit-open)"), "{p}"); + assert!( + p.contains(&format!("(allow {GUI_POSIX_SHM_OPERATIONS})")), + "{p}" + ); + assert!(p.contains("(allow mach-lookup)"), "{p}"); + assert!( + p.contains("(allow mach-register (global-name-prefix \"\"))"), + "{p}" + ); + + let config = r.seatbelt.as_mut().unwrap(); + config.custom_mach_registers = Some(SeatbeltCustomMachRegisters { + global: Some(custom_rules(SeatbeltCustomDefault::Deny, &[], &[])), + local: None, + }); + let p = build_profile(&r).unwrap(); + assert!( + p.contains("(allow mach-register (local-name-prefix \"\"))"), + "{p}" + ); + } + + /// Custom lists apply without guiAccess too, and `default: "allow"` + /// grants the whole operation in that scope. + #[test] + fn custom_default_allow_grants_the_scope() { + let mut r = req(); + r.seatbelt = Some(SeatbeltConfig { + custom_io_kits: Some(custom_rules(SeatbeltCustomDefault::Allow, &[], &[])), + custom_mach_registers: Some(SeatbeltCustomMachRegisters { + global: None, + local: Some(custom_rules(SeatbeltCustomDefault::Allow, &[], &[])), + }), + custom_posix_shm: Some(SeatbeltCustomPosixShm { + default: SeatbeltCustomDefault::Allow, + ..Default::default() + }), + ..Default::default() + }); + let p = build_profile(&r).unwrap(); + assert!(p.contains("(allow iokit-open)\n"), "{p}"); + assert!( + p.contains(&format!("(allow {GUI_POSIX_SHM_OPERATIONS})\n")), + "{p}" + ); + assert!( + p.contains("(allow mach-register (local-name-prefix \"\"))"), + "{p}" + ); + assert!(!p.contains("(allow mach-lookup)"), "{p}"); + } + + /// Custom denies follow the allows from explicit settings, and those + /// settings' denies follow the custom allows. + #[test] + fn custom_rules_order_around_setting_rules() { + let mut r = custom_request(); + r.policy.ui.injection = false; + r.policy.denied_paths = vec!["/private/tmp/denied".into()]; + let p = build_profile(&r).unwrap(); + let custom_allow = p.find(";; --- customMachLookups.allow").unwrap(); + let keychain_deny = p.find("keychainAccess=false: deny").unwrap(); + let injection_deny = p.find("ui.injection=false").unwrap(); + let custom_deny = p.find(";; --- customMachLookups.deny").unwrap(); + let fs_deny = p.find(";; --- policy.deniedPaths").unwrap(); + assert!(custom_allow < injection_deny && custom_allow < keychain_deny); + assert!(keychain_deny < custom_deny && custom_deny < fs_deny); + assert!(p.find("ui enabled: allow WindowServer").unwrap() < custom_deny); + let shm_own = p.find(";; --- customPosixShm.own").unwrap(); + let shm_deny = p.find(";; --- customPosixShm.deny").unwrap(); + assert!(custom_allow < shm_own && shm_own < custom_deny); + assert!(custom_deny < shm_deny && shm_deny < fs_deny); + } + + #[test] + fn capture_reports_custom_default_allow_grants() { + let mut r = capture_request(CaptureDenialsMode::Block, true); + r.seatbelt.as_mut().unwrap().custom_mach_lookups = + Some(custom_rules(SeatbeltCustomDefault::Allow, &[], &[])); + let p = capture_profile(&r); + assert!( + p.contains( + ";; --- customMachLookups: default allow ---\n\ + (allow mach-lookup (with report) (with message \"mxc-capture-test:grant\"))\n" + ), + "{p}" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn custom_rule_profiles_compile_in_the_macos_sandbox() { + let mut allow_all = custom_request(); + let config = allow_all.seatbelt.as_mut().unwrap(); + config.gui_access = false; + config.custom_io_kits.as_mut().unwrap().default = SeatbeltCustomDefault::Allow; + let registers = config.custom_mach_registers.as_mut().unwrap(); + registers.global.as_mut().unwrap().default = SeatbeltCustomDefault::Allow; + registers.local.as_mut().unwrap().default = SeatbeltCustomDefault::Allow; + config.custom_posix_shm.as_mut().unwrap().default = SeatbeltCustomDefault::Allow; + for request in [custom_request(), allow_all] { + let profile = build_profile(&request).unwrap(); + let output = std::process::Command::new("/usr/bin/sandbox-exec") + .args(["-p", &profile, "/usr/bin/true"]) + .output() + .expect("sandbox-exec runs"); + assert!( + output.status.success(), + "{}\n{profile}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + #[cfg(target_os = "macos")] #[test] fn capture_profiles_compile_in_the_macos_sandbox() { diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs index d96b577c4f..d1ad047206 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs @@ -11,7 +11,10 @@ //! `ScriptResponse`. use crate::mxc_common::host_is_canonical_loopback; -use crate::mxc_common::models::{ContainerPolicy, ExecutionRequest, NetworkAction}; +use crate::mxc_common::models::{ + ClipboardPolicy, ContainerPolicy, ExecutionRequest, NetworkAction, SeatbeltConfig, + SeatbeltCustomRules, +}; /// Effective GUI posture: `seatbelt.guiAccess` only means anything when the UI /// policy leaves UI enabled, since every GUI grant is emitted alongside the @@ -45,6 +48,323 @@ pub fn validate_seatbelt_ui_policy(request: &ExecutionRequest) -> Result<(), Str /// Mach name prefix reserved for MXC's per-run process cleanup rules. pub const RUN_MARKER_NAME_PREFIX: &str = "com.microsoft.mxc.run."; +/// Keychain servers, denied unless `keychainAccess` is set. `SecurityServer` +/// serves file-based keychains; the `com.apple.securityd` family serves the +/// data-protection keychain. +pub const KEYCHAIN_SERVER_NAMES: &[&str] = &["com.apple.SecurityServer"]; +pub const KEYCHAIN_SERVER_PREFIXES: &[&str] = &["com.apple.securityd"]; +/// Keychain servers that `keychainAccess: true` grants by name. +pub const KEYCHAIN_GRANTED_NAMES: &[&str] = &["com.apple.SecurityServer", "com.apple.securityd"]; +/// Mach services controlled by `ui.disable`. +pub const UI_SERVICE_NAMES: &[&str] = &[ + "com.apple.windowserver.active", + "com.apple.windowserver.session", + "com.apple.coreservices.launchservicesd", +]; +/// Pasteboard server that a permitting `ui.clipboard` grants. +pub const PASTEBOARD_SERVICE_NAME: &str = "com.apple.pasteboard.1"; +/// Pasteboard servers, denied when `ui.clipboard` is `"none"`. +pub const PASTEBOARD_SERVICE_PREFIX: &str = "com.apple.pasteboard."; +/// IOKit user-client class controlled by `ui.injection`. +pub const HID_USER_CLIENT_CLASS: &str = "IOHIDLibUserClient"; +/// Longest POSIX shared memory name macOS accepts (`PSHMNAMLEN`). +const POSIX_SHM_NAME_MAX: usize = 31; + +/// One entry of a Seatbelt custom rule list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NamePattern<'a> { + Exact(&'a str), + /// An entry ending in `*`, stored without it. + Prefix(&'a str), +} + +impl<'a> NamePattern<'a> { + pub fn parse(entry: &'a str) -> Self { + match entry.strip_suffix('*') { + Some(prefix) => Self::Prefix(prefix), + None => Self::Exact(entry), + } + } + + fn text(self) -> &'a str { + match self { + Self::Exact(text) | Self::Prefix(text) => text, + } + } + + /// Whether every name this pattern matches also matches `other`. + fn within(self, other: NamePattern<'_>) -> bool { + match (self, other) { + (NamePattern::Exact(name), NamePattern::Exact(other)) => name == other, + (_, NamePattern::Prefix(other)) => self.text().starts_with(other), + (NamePattern::Prefix(_), NamePattern::Exact(_)) => false, + } + } + + /// Whether some name matches both patterns. + fn overlaps(self, other: NamePattern<'_>) -> bool { + self.within(other) || other.within(self) + } +} + +/// Every custom rule list with its JSON name, and whether its entries are +/// IOKit class names. +fn custom_rule_sets( + config: &SeatbeltConfig, +) -> Vec<(&'static str, &SeatbeltCustomRules, CustomNameKind)> { + let registers = config.custom_mach_registers.as_ref(); + [ + ( + "customIoKits", + config.custom_io_kits.as_ref(), + CustomNameKind::IoKit, + ), + ( + "customMachLookups", + config.custom_mach_lookups.as_ref(), + CustomNameKind::MachLookup, + ), + ( + "customMachRegisters.global", + registers.and_then(|r| r.global.as_ref()), + CustomNameKind::MachRegister, + ), + ( + "customMachRegisters.local", + registers.and_then(|r| r.local.as_ref()), + CustomNameKind::MachRegister, + ), + ] + .into_iter() + .filter_map(|(field, rules, kind)| rules.map(|rules| (field, rules, kind))) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CustomNameKind { + IoKit, + MachLookup, + MachRegister, + PosixShm, +} + +/// Names that an explicit setting allows or denies. +struct SettingNames { + patterns: Vec>, + description: &'static str, + setting: &'static str, +} + +/// Names that explicit settings deny and grant, for one kind of name. +fn setting_names( + request: &ExecutionRequest, + kind: CustomNameKind, +) -> (Vec, Vec) { + let ui = &request.policy.ui; + let mut denied = Vec::new(); + let mut granted = Vec::new(); + match kind { + CustomNameKind::MachLookup => { + let keychain = request.seatbelt.as_ref().is_some_and(|c| c.keychain_access); + let (target, patterns) = if keychain { + ( + &mut granted, + KEYCHAIN_GRANTED_NAMES + .iter() + .map(|n| NamePattern::Exact(n)) + .collect(), + ) + } else { + ( + &mut denied, + KEYCHAIN_SERVER_NAMES + .iter() + .map(|n| NamePattern::Exact(n)) + .chain( + KEYCHAIN_SERVER_PREFIXES + .iter() + .map(|p| NamePattern::Prefix(p)), + ) + .collect(), + ) + }; + target.push(SettingNames { + patterns, + description: "a Keychain server", + setting: "seatbelt.keychainAccess", + }); + let (target, pattern) = if matches!(ui.clipboard, ClipboardPolicy::None) { + (&mut denied, NamePattern::Prefix(PASTEBOARD_SERVICE_PREFIX)) + } else { + (&mut granted, NamePattern::Exact(PASTEBOARD_SERVICE_NAME)) + }; + target.push(SettingNames { + patterns: vec![pattern], + description: "a pasteboard server", + setting: "ui.clipboard", + }); + let target = if ui.disable { + &mut denied + } else { + &mut granted + }; + target.push(SettingNames { + patterns: UI_SERVICE_NAMES + .iter() + .map(|n| NamePattern::Exact(n)) + .collect(), + description: "a WindowServer or LaunchServices service", + setting: "ui.disable", + }); + } + CustomNameKind::IoKit if !ui.injection => denied.push(SettingNames { + patterns: vec![NamePattern::Exact(HID_USER_CLIENT_CLASS)], + description: "the HID input client", + setting: "ui.injection", + }), + CustomNameKind::IoKit | CustomNameKind::MachRegister | CustomNameKind::PosixShm => {} + } + (denied, granted) +} + +/// Reject allow entries that only name what a setting denies, and deny +/// entries that would remove what a setting grants. +fn check_setting_conflicts( + request: &ExecutionRequest, + kind: CustomNameKind, + (allow_list, allow): (&str, &[String]), + (deny_list, deny): (&str, &[String]), +) -> Result<(), String> { + let (denied, granted) = setting_names(request, kind); + for entry in allow { + let pattern = NamePattern::parse(entry); + if let Some(names) = denied + .iter() + .find(|names| names.patterns.iter().any(|d| pattern.within(*d))) + { + return Err(format!( + "Seatbelt: {allow_list} entry '{entry}' names {}, which the current {} \ + setting denies. Change {} instead.", + names.description, names.setting, names.setting + )); + } + } + for entry in deny { + let pattern = NamePattern::parse(entry); + if let Some(names) = granted + .iter() + .find(|names| names.patterns.iter().any(|g| pattern.overlaps(*g))) + { + return Err(format!( + "Seatbelt: {deny_list} entry '{entry}' would deny {}, which the current {} \ + setting allows. Change {} instead.", + names.description, names.setting, names.setting + )); + } + } + Ok(()) +} + +fn validate_custom_entry(list: &str, entry: &str, kind: CustomNameKind) -> Result<(), String> { + let name = match NamePattern::parse(entry) { + NamePattern::Exact(name) | NamePattern::Prefix(name) => name, + }; + if name.is_empty() { + return Err(format!( + "Seatbelt: {list} entries must be non-empty names; a bare '*' is not \ + supported. Set 'default' to \"allow\" to allow every name." + )); + } + if name.contains('*') { + return Err(format!( + "Seatbelt: {list} entry '{entry}' has a '*' that is not at the end. \ + Only a single trailing '*' (prefix match) is supported." + )); + } + let is_class_name = |name: &str| { + name.chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + }; + if kind == CustomNameKind::IoKit && !is_class_name(name) { + return Err(format!( + "Seatbelt: {list} entry '{entry}' is not an IOKit user-client class name \ + (letters, digits, and '_', optionally ending in '*')." + )); + } + if kind == CustomNameKind::PosixShm && name.len() > POSIX_SHM_NAME_MAX { + return Err(format!( + "Seatbelt: {list} entry '{entry}' is longer than {POSIX_SHM_NAME_MAX} bytes, \ + the longest POSIX shared memory name macOS accepts." + )); + } + Ok(()) +} + +/// Validate `customIoKits`, `customMachLookups`, `customMachRegisters`, +/// `customPosixShm`, and the `extraMachLookups` entries they interact with. +pub fn validate_seatbelt_custom_rules(request: &ExecutionRequest) -> Result<(), String> { + let Some(config) = request.seatbelt.as_ref() else { + return Ok(()); + }; + let sets = custom_rule_sets(config); + let has_custom = !sets.is_empty() || config.custom_posix_shm.is_some(); + if has_custom && config.profile_override.is_some() { + return Err( + "Seatbelt: seatbelt.profileOverride replaces the generated profile, so \ + it cannot be combined with customIoKits, customMachLookups, \ + customMachRegisters, or customPosixShm. Remove profileOverride, or \ + write those rules into the override." + .to_string(), + ); + } + if config.custom_mach_lookups.is_some() && !config.extra_mach_lookups.is_empty() { + return Err( + "Seatbelt: seatbelt.extraMachLookups cannot be combined with \ + seatbelt.customMachLookups. Move the extraMachLookups entries into \ + customMachLookups.allow." + .to_string(), + ); + } + check_setting_conflicts( + request, + CustomNameKind::MachLookup, + ("seatbelt.extraMachLookups", &config.extra_mach_lookups), + ("", &[]), + )?; + for (field, rules, kind) in sets { + let allow_list = format!("seatbelt.{field}.allow"); + let deny_list = format!("seatbelt.{field}.deny"); + for entry in &rules.allow { + validate_custom_entry(&allow_list, entry, kind)?; + } + for entry in &rules.deny { + validate_custom_entry(&deny_list, entry, kind)?; + } + check_setting_conflicts( + request, + kind, + (&allow_list, &rules.allow), + (&deny_list, &rules.deny), + )?; + } + if let Some(shm) = config.custom_posix_shm.as_ref() { + for (list, entries) in [ + ("read", &shm.read), + ("readWrite", &shm.read_write), + ("own", &shm.own), + ("deny", &shm.deny), + ] { + let list = format!("seatbelt.customPosixShm.{list}"); + for entry in entries { + validate_custom_entry(&list, entry, CustomNameKind::PosixShm)?; + } + } + } + Ok(()) +} + /// Reject caller-supplied names in the reserved cleanup prefix. The rules MXC /// appends to every profile override them, so they could never take effect. pub fn validate_seatbelt_reserved_names(request: &ExecutionRequest) -> Result<(), String> { @@ -62,15 +382,32 @@ pub fn validate_seatbelt_reserved_names(request: &ExecutionRequest) -> Result<() specific to the run that printed them); MXC adds this run's own." )); } - if let Some(name) = config - .extra_mach_lookups - .iter() - .find(|name| name.starts_with(RUN_MARKER_NAME_PREFIX)) - { - return Err(format!( - "Seatbelt: seatbelt.extraMachLookups entry '{name}' uses the prefix \ - '{RUN_MARKER_NAME_PREFIX}', which MXC reserves for its process cleanup rules." - )); + let custom_lookups = config.custom_mach_lookups.as_ref(); + let lookup_lists = [ + ( + "seatbelt.extraMachLookups", + Some(&config.extra_mach_lookups), + ), + ( + "seatbelt.customMachLookups.allow", + custom_lookups.map(|rules| &rules.allow), + ), + ( + "seatbelt.customMachLookups.deny", + custom_lookups.map(|rules| &rules.deny), + ), + ]; + for (list, entries) in lookup_lists { + if let Some(name) = entries + .into_iter() + .flatten() + .find(|name| name.starts_with(RUN_MARKER_NAME_PREFIX)) + { + return Err(format!( + "Seatbelt: {list} entry '{name}' uses the prefix \ + '{RUN_MARKER_NAME_PREFIX}', which MXC reserves for its process cleanup rules." + )); + } } Ok(()) } @@ -176,7 +513,9 @@ pub fn validate_seatbelt_capture_denials(request: &ExecutionRequest) -> Result<( #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::models::{ProxyAddress, ProxyConfig, SeatbeltConfig}; + use crate::mxc_common::models::{ + ProxyAddress, ProxyConfig, SeatbeltConfig, SeatbeltCustomPosixShm, + }; #[test] fn omitted_host_loopback_defaults_to_deny() { @@ -337,6 +676,200 @@ mod tests { assert!(validate_seatbelt_reserved_names(&request).is_ok()); } + fn lookups(allow: &[&str], deny: &[&str]) -> SeatbeltConfig { + SeatbeltConfig { + custom_mach_lookups: Some(SeatbeltCustomRules { + allow: allow.iter().map(|s| s.to_string()).collect(), + deny: deny.iter().map(|s| s.to_string()).collect(), + ..Default::default() + }), + ..Default::default() + } + } + + fn custom_error(config: SeatbeltConfig) -> String { + validate_seatbelt_custom_rules(&seatbelt_request(config)).unwrap_err() + } + + #[test] + fn rejects_malformed_custom_entries() { + for (entry, expected) in [ + ("*", "bare '*'"), + ("", "non-empty"), + ("com.*.service", "not at the end"), + ("com.example**", "not at the end"), + ] { + let msg = custom_error(lookups(&[entry], &[])); + assert!(msg.contains("customMachLookups.allow"), "{entry}: {msg}"); + assert!(msg.contains(expected), "{entry}: {msg}"); + } + for entry in ["com.apple.Foo", "9Class", "IO-Surface*"] { + let msg = custom_error(SeatbeltConfig { + custom_io_kits: Some(SeatbeltCustomRules { + deny: vec![entry.into()], + ..Default::default() + }), + ..Default::default() + }); + assert!(msg.contains("customIoKits.deny"), "{entry}: {msg}"); + assert!(msg.contains("IOKit user-client class"), "{entry}: {msg}"); + } + let shm = |own: &str| SeatbeltConfig { + custom_posix_shm: Some(SeatbeltCustomPosixShm { + own: vec![own.into()], + ..Default::default() + }), + ..Default::default() + }; + let longest = format!("/{}", "a".repeat(30)); + assert!(validate_seatbelt_custom_rules(&seatbelt_request(shm(&longest))).is_ok()); + for (entry, expected) in [ + (format!("{longest}b"), "longer than 31 bytes"), + ("*".to_string(), "bare '*'"), + ] { + let msg = custom_error(shm(&entry)); + assert!(msg.contains("customPosixShm.own"), "{entry}: {msg}"); + assert!(msg.contains(expected), "{entry}: {msg}"); + } + } + + #[test] + fn rejects_custom_rules_that_cannot_apply() { + let msg = custom_error(SeatbeltConfig { + profile_override: Some("(version 1)".into()), + ..lookups(&["com.example.a"], &[]) + }); + assert!(msg.contains("profileOverride"), "{msg}"); + + let msg = custom_error(SeatbeltConfig { + profile_override: Some("(version 1)".into()), + custom_posix_shm: Some(SeatbeltCustomPosixShm::default()), + ..Default::default() + }); + assert!(msg.contains("profileOverride"), "{msg}"); + + let msg = custom_error(SeatbeltConfig { + extra_mach_lookups: vec!["com.example.a".into()], + ..lookups(&[], &[]) + }); + assert!(msg.contains("customMachLookups.allow"), "{msg}"); + } + + /// Explicit settings decide their own names; custom lists cannot + /// contradict them. + #[test] + fn rejects_custom_entries_that_contradict_settings() { + for (config, list, setting) in [ + ( + lookups(&["com.apple.SecurityServer"], &[]), + "customMachLookups.allow", + "keychainAccess", + ), + ( + lookups(&["com.apple.securityd.xpc*"], &[]), + "customMachLookups.allow", + "keychainAccess", + ), + ( + SeatbeltConfig { + extra_mach_lookups: vec!["com.apple.securityd".into()], + ..Default::default() + }, + "extraMachLookups", + "keychainAccess", + ), + ( + SeatbeltConfig { + keychain_access: true, + ..lookups(&[], &["com.apple.Security*"]) + }, + "customMachLookups.deny", + "keychainAccess", + ), + ( + lookups(&["com.apple.pasteboard.1"], &[]), + "customMachLookups.allow", + "ui.clipboard", + ), + ( + lookups(&["com.apple.pasteboard.*"], &[]), + "customMachLookups.allow", + "ui.clipboard", + ), + ( + SeatbeltConfig { + extra_mach_lookups: vec!["com.apple.pasteboard.2".into()], + ..Default::default() + }, + "extraMachLookups", + "ui.clipboard", + ), + ( + lookups(&["com.apple.windowserver.active"], &[]), + "customMachLookups.allow", + "ui.disable", + ), + ( + SeatbeltConfig { + custom_io_kits: Some(SeatbeltCustomRules { + allow: vec!["IOHIDLibUserClient".into()], + ..Default::default() + }), + ..Default::default() + }, + "customIoKits.allow", + "ui.injection", + ), + ] { + let mut request = seatbelt_request(config); + request.policy.ui.injection = false; + let msg = validate_seatbelt_custom_rules(&request).unwrap_err(); + assert!(msg.contains(list), "{list}: {msg}"); + assert!(msg.contains(setting), "{list}: {msg}"); + } + + let mut request = seatbelt_request(lookups(&[], &["com.apple.windowserver.*"])); + request.policy.ui.disable = false; + let msg = validate_seatbelt_custom_rules(&request).unwrap_err(); + assert!(msg.contains("ui.disable"), "{msg}"); + + let mut request = seatbelt_request(lookups(&[], &["com.apple.pasteboard.*"])); + request.policy.ui.clipboard = ClipboardPolicy::All; + let msg = validate_seatbelt_custom_rules(&request).unwrap_err(); + assert!(msg.contains("ui.clipboard"), "{msg}"); + } + + #[test] + fn accepts_custom_entries_consistent_with_settings() { + // A broad prefix may cover a denied name; the setting's deny wins. + let mut request = seatbelt_request(lookups( + &["com.apple.*", "com.example.service"], + &["com.apple.windowserver.*", "com.apple.pasteboard.1"], + )); + request.policy.ui.injection = false; + request.seatbelt.as_mut().unwrap().custom_io_kits = Some(SeatbeltCustomRules { + allow: vec!["AGXDeviceUserClient".into(), "IO*".into()], + deny: vec!["IOHIDLibUserClient".into()], + ..Default::default() + }); + validate_seatbelt_custom_rules(&request).unwrap(); + validate_seatbelt_custom_rules(&seatbelt_request(SeatbeltConfig { + keychain_access: true, + ..lookups(&["com.apple.SecurityServer"], &[]) + })) + .unwrap(); + } + + #[test] + fn rejects_reserved_names_in_custom_lookups() { + let msg = validate_seatbelt_reserved_names(&seatbelt_request(lookups( + &[], + &["com.microsoft.mxc.run.*"], + ))) + .unwrap_err(); + assert!(msg.contains("customMachLookups.deny"), "{msg}"); + } + #[test] fn gui_access_effective_requires_both_flags() { assert!(gui_access_effective(&gui_request(true, false))); diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs index 3ae193f3db..34d01a291f 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs @@ -173,6 +173,8 @@ impl SandboxBackend for SeatbeltScriptRunner { .map_err(rejected_response)?; crate::seatbelt_common::seatbelt_policy::validate_seatbelt_reserved_names(request) .map_err(rejected_response)?; + crate::seatbelt_common::seatbelt_policy::validate_seatbelt_custom_rules(request) + .map_err(rejected_response)?; // Dry runs never reach spawn, so reject unresolvable paths here too. prepare_profile(request).map_err(profile_error_response)?; prepare_capture_output_path(request).map_err(profile_error_response)?; @@ -1370,11 +1372,7 @@ else: "#, &[ path.to_str().expect("UTF-8 terminal path"), - if nested_pty || gui_access { - "allow" - } else { - "deny" - }, + if nested_pty { "allow" } else { "deny" }, ], with_pty, ); @@ -1395,9 +1393,11 @@ else: assert_unrelated_terminal_denied(false, false, false); } + /// `nestedPty` alone controls PTY allocation; `guiAccess` does not grant it. #[test] fn gui_access_cannot_open_unrelated_host_terminals() { assert_unrelated_terminal_denied(false, true, false); + assert_unrelated_terminal_denied(true, true, false); } #[test] diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs index b2000506cd..0d39ac31d0 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs @@ -183,6 +183,10 @@ fn convert_seatbelt(value: contract::Seatbelt) -> wire::Seatbelt { nested_pty, keychain_access, extra_mach_lookups, + custom_io_kits, + custom_mach_registers, + custom_mach_lookups, + custom_posix_shm, capture_denials, } = value; wire::Seatbelt { @@ -191,12 +195,77 @@ fn convert_seatbelt(value: contract::Seatbelt) -> wire::Seatbelt { nested_pty: nested_pty.into_option(), keychain_access: keychain_access.into_option(), extra_mach_lookups: extra_mach_lookups.into_option(), + custom_io_kits: custom_io_kits + .into_option() + .map(convert_seatbelt_custom_rules), + custom_mach_registers: custom_mach_registers + .into_option() + .map(convert_seatbelt_custom_mach_registers), + custom_mach_lookups: custom_mach_lookups + .into_option() + .map(convert_seatbelt_custom_rules), + custom_posix_shm: custom_posix_shm + .into_option() + .map(convert_seatbelt_custom_posix_shm), capture_denials: capture_denials .into_option() .map(convert_seatbelt_capture_denials), } } +fn convert_seatbelt_custom_rules( + value: contract::SeatbeltCustomRules, +) -> wire::SeatbeltCustomRules { + let contract::SeatbeltCustomRules { + default, + allow, + deny, + } = value; + wire::SeatbeltCustomRules { + default: default.into_option().map(convert_seatbelt_custom_default), + allow: allow.into_option(), + deny: deny.into_option(), + } +} + +fn convert_seatbelt_custom_default( + value: contract::SeatbeltCustomDefault, +) -> wire::SeatbeltCustomDefault { + match value { + contract::SeatbeltCustomDefault::Allow => wire::SeatbeltCustomDefault::Allow, + contract::SeatbeltCustomDefault::Deny => wire::SeatbeltCustomDefault::Deny, + } +} + +fn convert_seatbelt_custom_posix_shm( + value: contract::SeatbeltCustomPosixShm, +) -> wire::SeatbeltCustomPosixShm { + let contract::SeatbeltCustomPosixShm { + default, + read, + read_write, + own, + deny, + } = value; + wire::SeatbeltCustomPosixShm { + default: default.into_option().map(convert_seatbelt_custom_default), + read: read.into_option(), + read_write: read_write.into_option(), + own: own.into_option(), + deny: deny.into_option(), + } +} + +fn convert_seatbelt_custom_mach_registers( + value: contract::SeatbeltCustomMachRegisters, +) -> wire::SeatbeltCustomMachRegisters { + let contract::SeatbeltCustomMachRegisters { global, local } = value; + wire::SeatbeltCustomMachRegisters { + global: global.into_option().map(convert_seatbelt_custom_rules), + local: local.into_option().map(convert_seatbelt_custom_rules), + } +} + fn convert_seatbelt_capture_denials( value: contract::SeatbeltCaptureDenials, ) -> wire::SeatbeltCaptureDenials { diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs index eea8dce7a0..c56296c706 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs @@ -183,6 +183,10 @@ fn convert_seatbelt(value: contract::Seatbelt) -> wire::Seatbelt { nested_pty: nested_pty.into_option(), keychain_access: keychain_access.into_option(), extra_mach_lookups: extra_mach_lookups.into_option(), + custom_io_kits: None, + custom_mach_registers: None, + custom_mach_lookups: None, + custom_posix_shm: None, capture_denials: None, } } diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs index 190fb3c78c..063ff8e3f6 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs @@ -183,6 +183,10 @@ fn convert_seatbelt(value: contract::Seatbelt) -> wire::Seatbelt { nested_pty: nested_pty.into_option(), keychain_access: keychain_access.into_option(), extra_mach_lookups: extra_mach_lookups.into_option(), + custom_io_kits: None, + custom_mach_registers: None, + custom_mach_lookups: None, + custom_posix_shm: None, capture_denials: None, } } diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index 8e5e5e19c4..15d9c06089 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -9,7 +9,9 @@ use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ CaptureDenialsConfig, CaptureDenialsMode, ContainerPolicy, ContainmentBackend, ExecutionRequest, HyperlightConfig, LifecycleConfig, LxcConfig, PortMapping, SeatbeltConfig, - TelemetryConfig, TestFeatureConfig, UiPolicy, WindowsSandboxConfig, WslcConfig, + SeatbeltCustomDefault, SeatbeltCustomMachRegisters, SeatbeltCustomPosixShm, + SeatbeltCustomRules, TelemetryConfig, TestFeatureConfig, UiPolicy, WindowsSandboxConfig, + WslcConfig, }; use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::network_parser::{parse_network_policy, NetworkSections}; @@ -1272,6 +1274,10 @@ fn make_seatbelt_config(sb: wire::Seatbelt) -> SeatbeltConfig { nested_pty, keychain_access, extra_mach_lookups, + custom_io_kits, + custom_mach_registers, + custom_mach_lookups, + custom_posix_shm, capture_denials, } = sb; // outputPath may start with `~`; the backend expands and validates it. @@ -1295,10 +1301,55 @@ fn make_seatbelt_config(sb: wire::Seatbelt) -> SeatbeltConfig { nested_pty: nested_pty.unwrap_or(true), keychain_access: keychain_access.unwrap_or(false), extra_mach_lookups: extra_mach_lookups.unwrap_or_default(), + custom_io_kits: custom_io_kits.map(make_seatbelt_custom_rules), + custom_mach_registers: custom_mach_registers.map( + |wire::SeatbeltCustomMachRegisters { global, local }| SeatbeltCustomMachRegisters { + global: global.map(make_seatbelt_custom_rules), + local: local.map(make_seatbelt_custom_rules), + }, + ), + custom_mach_lookups: custom_mach_lookups.map(make_seatbelt_custom_rules), + custom_posix_shm: custom_posix_shm.map( + |wire::SeatbeltCustomPosixShm { + default, + read, + read_write, + own, + deny, + }| SeatbeltCustomPosixShm { + default: make_seatbelt_custom_default(default), + read: read.unwrap_or_default(), + read_write: read_write.unwrap_or_default(), + own: own.unwrap_or_default(), + deny: deny.unwrap_or_default(), + }, + ), capture_denials, } } +fn make_seatbelt_custom_default( + default: Option, +) -> SeatbeltCustomDefault { + match default { + Some(wire::SeatbeltCustomDefault::Allow) => SeatbeltCustomDefault::Allow, + Some(wire::SeatbeltCustomDefault::Deny) | None => SeatbeltCustomDefault::Deny, + } +} + +fn make_seatbelt_custom_rules(rules: wire::SeatbeltCustomRules) -> SeatbeltCustomRules { + let wire::SeatbeltCustomRules { + default, + allow, + deny, + } = rules; + SeatbeltCustomRules { + default: make_seatbelt_custom_default(default), + allow: allow.unwrap_or_default(), + deny: deny.unwrap_or_default(), + } +} + /// Resolve the optional `containment` wire enum to a concrete domain backend. /// /// An omitted `containment` (`None`) resolves identically to the abstract @@ -2384,6 +2435,44 @@ mod tests { assert_eq!(capture.output_path.as_deref(), Some("~/denials.json")); } + #[test] + fn development_seatbelt_custom_rules_map_to_config() { + let json = r#"{ + "version": "1.1.0-alpha", + "containment": "seatbelt", + "seatbelt": { + "customIoKits": {"allow": ["AGXDeviceUserClient"]}, + "customMachLookups": {"default": "allow", "deny": ["com.example.*"]}, + "customMachRegisters": {"local": {}}, + "customPosixShm": {"readWrite": ["/myapp.*"], "own": ["/myapp.tmp"]} + }, + "process": {"commandLine": "echo dev"} + }"#; + let MxcRequest::OneShot(request) = parse_exact_for_test(json).unwrap() else { + panic!("expected one-shot request"); + }; + let seatbelt = request.seatbelt.unwrap(); + let io_kits = seatbelt.custom_io_kits.unwrap(); + assert_eq!(io_kits.default, SeatbeltCustomDefault::Deny); + assert_eq!(io_kits.allow, ["AGXDeviceUserClient"]); + assert!(io_kits.deny.is_empty()); + let lookups = seatbelt.custom_mach_lookups.unwrap(); + assert_eq!(lookups.default, SeatbeltCustomDefault::Allow); + assert_eq!(lookups.deny, ["com.example.*"]); + let registers = seatbelt.custom_mach_registers.unwrap(); + assert!(registers.global.is_none()); + assert_eq!( + registers.local.unwrap().default, + SeatbeltCustomDefault::Deny + ); + let shm = seatbelt.custom_posix_shm.unwrap(); + assert_eq!(shm.default, SeatbeltCustomDefault::Deny); + assert!(shm.read.is_empty()); + assert_eq!(shm.read_write, ["/myapp.*"]); + assert_eq!(shm.own, ["/myapp.tmp"]); + assert!(shm.deny.is_empty()); + } + #[test] fn exact_parser_accepts_the_development_one_shot_contract() { let json = r#"{ diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index 90ab00c7e3..aac81b301b 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -180,6 +180,29 @@ pub struct SeatbeltConfig { )] pub extra_mach_lookups: Vec, + /// IOKit user-client class rules. When present, they replace guiAccess's + /// IOKit grant. + #[serde(rename = "customIoKits", skip_serializing_if = "Option::is_none")] + pub custom_io_kits: Option, + + /// Mach registration rules. Each present scope replaces guiAccess's + /// registration grant for that scope. + #[serde( + rename = "customMachRegisters", + skip_serializing_if = "Option::is_none" + )] + pub custom_mach_registers: Option, + + /// Mach lookup rules. When present, they replace guiAccess's Mach lookup + /// grant. + #[serde(rename = "customMachLookups", skip_serializing_if = "Option::is_none")] + pub custom_mach_lookups: Option, + + /// POSIX shared memory rules. When present, they replace guiAccess's + /// shared memory grant. + #[serde(rename = "customPosixShm", skip_serializing_if = "Option::is_none")] + pub custom_posix_shm: Option, + /// Records sandbox denials through the unified log while the workload /// runs. On macOS, `retain_etl` keeps the raw log as `.seatbelt.log`. #[serde(rename = "captureDenials", skip_serializing_if = "Option::is_none")] @@ -198,11 +221,57 @@ impl Default for SeatbeltConfig { nested_pty: true, keychain_access: false, extra_mach_lookups: Vec::new(), + custom_io_kits: None, + custom_mach_registers: None, + custom_mach_lookups: None, + custom_posix_shm: None, capture_denials: None, } } } +/// Access for names that no Seatbelt custom rule entry matches. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum SeatbeltCustomDefault { + /// Every name not denied is allowed. + Allow, + /// Only listed names are allowed. + #[default] + Deny, +} + +/// Seatbelt allow and deny lists for one kind of name. An entry ending in +/// `*` matches every name with that prefix; `deny` entries win. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SeatbeltCustomRules { + pub default: SeatbeltCustomDefault, + pub allow: Vec, + pub deny: Vec, +} + +/// Seatbelt Mach registration rules for global and per-process names. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SeatbeltCustomMachRegisters { + pub global: Option, + pub local: Option, +} + +/// Seatbelt POSIX shared memory rules. Each list grants more than the one +/// before it; `deny` entries win. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SeatbeltCustomPosixShm { + /// `Allow` grants read, write, and create, but not delete. + pub default: SeatbeltCustomDefault, + pub read: Vec, + pub read_write: Vec, + /// Also grants delete. + pub own: Vec, + pub deny: Vec, +} + /// Configuration specific to the Windows Sandbox backend. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] diff --git a/src/mxc-sdk/src/core/mxc_common/wire.rs b/src/mxc-sdk/src/core/mxc_common/wire.rs index de8f16acf1..741801ed8e 100644 --- a/src/mxc-sdk/src/core/mxc_common/wire.rs +++ b/src/mxc-sdk/src/core/mxc_common/wire.rs @@ -418,10 +418,56 @@ pub struct Seatbelt { pub keychain_access: Option, /// Additional Mach service global-names the inner process may resolve. pub extra_mach_lookups: Option>, + /// IOKit user-client class rules (development contract only). + pub custom_io_kits: Option, + /// Mach registration rules (development contract only). + pub custom_mach_registers: Option, + /// Mach lookup rules (development contract only). + pub custom_mach_lookups: Option, + /// POSIX shared memory rules (development contract only). + pub custom_posix_shm: Option, /// Seatbelt denial capture (development contract only). pub capture_denials: Option, } +/// Access for names that no Seatbelt custom rule entry matches. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SeatbeltCustomDefault { + Allow, + Deny, +} + +/// Seatbelt allow and deny lists for one kind of name. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SeatbeltCustomRules { + /// Defaults to `deny` when omitted. + pub default: Option, + pub allow: Option>, + pub deny: Option>, +} + +/// Seatbelt Mach registration rules, by name scope. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SeatbeltCustomMachRegisters { + pub global: Option, + pub local: Option, +} + +/// Seatbelt POSIX shared memory rules, by access. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SeatbeltCustomPosixShm { + /// Defaults to `deny` when omitted. + pub default: Option, + pub read: Option>, + pub read_write: Option>, + pub own: Option>, + pub deny: Option>, +} + /// macOS Seatbelt denial-capture settings. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/mod.rs b/src/mxc-sdk/src/core/mxc_contract/dev/mod.rs index 1a83316ad6..ddb9056acf 100644 --- a/src/mxc-sdk/src/core/mxc_contract/dev/mod.rs +++ b/src/mxc-sdk/src/core/mxc_contract/dev/mod.rs @@ -246,8 +246,9 @@ pub use stable::{ CaptureDenials, CaptureDenialsMode, Fallback, Filesystem, Lifecycle, Lxc, Process, ProcessContainer, ProcessContainerCapability, ProcessContainerFilesystem, ProcessContainerNetwork, ProcessContainerUi, ProcessContainerUiIsolation, - ProcessContainerUiSystemSettings, RuntimeConfig, Seatbelt, SeatbeltCaptureDenials, Telemetry, - Ui, UiClipboard, + ProcessContainerUiSystemSettings, RuntimeConfig, Seatbelt, SeatbeltCaptureDenials, + SeatbeltCustomDefault, SeatbeltCustomMachRegisters, SeatbeltCustomPosixShm, + SeatbeltCustomRules, Telemetry, Ui, UiClipboard, }; pub use state_aware::{probe_containment, Containment, ContainmentProbeError}; pub use state_aware::{probe_phase, Phase, PhaseProbeError}; diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/stable.rs b/src/mxc-sdk/src/core/mxc_contract/dev/stable.rs index e6fa643625..da321ef090 100644 --- a/src/mxc-sdk/src/core/mxc_contract/dev/stable.rs +++ b/src/mxc-sdk/src/core/mxc_contract/dev/stable.rs @@ -381,13 +381,95 @@ pub struct Seatbelt { #[serde(default)] pub keychain_access: OptionalField, /// Additional Mach service global names the process may resolve. + /// Deprecated: use `customMachLookups.allow`. #[serde(default)] pub extra_mach_lookups: OptionalField>, + /// IOKit user-client classes the process may open. When present, this + /// replaces guiAccess's IOKit grant. + #[serde(default)] + pub custom_io_kits: OptionalField, + /// Mach service names the process may register. Each present block + /// replaces guiAccess's registration grant for its names. + #[serde(default)] + pub custom_mach_registers: OptionalField, + /// Mach service global names the process may resolve. When present, this + /// replaces guiAccess's Mach lookup grant. + #[serde(default)] + pub custom_mach_lookups: OptionalField, + /// POSIX shared memory names the process may use. When present, this + /// replaces guiAccess's shared memory grant. + #[serde(default)] + pub custom_posix_shm: OptionalField, /// Records sandbox denials to a JSON report. #[serde(default)] pub capture_denials: OptionalField, } +string_enum! { + /// Access for names that no `allow` or `deny` entry matches. + #[derive(Debug)] + pub enum SeatbeltCustomDefault { + /// Allow every name not denied. Use only for compatibility. + Allow => ["allow"], + /// Allow only listed names. + Deny => ["deny"], + } +} + +/// Allow and deny lists for one kind of Seatbelt name. An entry ending in `*` +/// matches every name with that prefix. `deny` entries win over `allow`. +#[derive(Debug, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SeatbeltCustomRules { + /// Access for unlisted names. Defaults to `deny`. + #[serde(default)] + pub default: OptionalField, + /// Names to allow. + #[serde(default)] + pub allow: OptionalField>, + /// Names to deny, including names MXC allows by default. + #[serde(default)] + pub deny: OptionalField>, +} + +/// Mach service registration rules, by name scope. +#[derive(Debug, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SeatbeltCustomMachRegisters { + /// Names registered with launchd, visible to other processes. + #[serde(default)] + pub global: OptionalField, + /// Per-process names, shown as `(per-pid)` in denial reports. + #[serde(default)] + pub local: OptionalField, +} + +/// POSIX shared memory rules, by access. An entry ending in `*` matches every +/// name with that prefix. `deny` entries win over every other list. +#[derive(Debug, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SeatbeltCustomPosixShm { + /// Access for unlisted names. Defaults to `deny`. `allow` grants read, + /// write, and create. + #[serde(default)] + pub default: OptionalField, + /// Names the process may read. + #[serde(default)] + pub read: OptionalField>, + /// Existing names the process may read and write. + #[serde(default)] + pub read_write: OptionalField>, + /// Names the process may read, write, create, and delete. + #[serde(default)] + pub own: OptionalField>, + /// Names to deny. + #[serde(default)] + pub deny: OptionalField>, +} + /// macOS Seatbelt denial-capture settings. #[derive(Debug, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/seatbelt.rs b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/seatbelt.rs index fe1b4b3962..6be3e9a0bd 100644 --- a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/seatbelt.rs +++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/seatbelt.rs @@ -232,3 +232,51 @@ fn rejects_invalid_seatbelt_capture_denials() { assert_invalid(&json); } } + +#[test] +fn accepts_seatbelt_custom_rules() { + for body in [ + r#""customIoKits": {}"#, + r#""customIoKits": {"default": "deny", "allow": ["AGXDeviceUserClient", "IOSurface*"], "deny": []}"#, + r#""customMachLookups": {"default": "allow", "deny": ["com.example.*"]}"#, + r#""customMachRegisters": {}"#, + r#""customMachRegisters": {"global": {"allow": ["com.example.a"]}, "local": {"default": "allow"}}"#, + r#""customPosixShm": {}"#, + r#""customPosixShm": {"default": "deny", "read": ["apple.shm.*"], "readWrite": ["/app.rw"], "own": ["/app.*"], "deny": ["/app.secret"]}"#, + ] { + let json = format!( + r#"{{ + "version": "1.1.0-alpha", + "seatbelt": {{{body}}}, + "process": {{"commandLine": "echo"}} + }}"# + ); + + assert_valid(&json); + } +} + +#[test] +fn rejects_invalid_seatbelt_custom_rules() { + for body in [ + r#""customIoKits": true"#, + r#""customIoKits": {"default": "audit"}"#, + r#""customMachLookups": {"allow": "com.example.a"}"#, + r#""customMachLookups": {"extra": []}"#, + r#""customMachRegisters": {"global": {"default": "allow"}, "session": {}}"#, + r#""customMachRegisters": {"default": "allow"}"#, + r#""customPosixShm": true"#, + r#""customPosixShm": {"allow": ["/app"]}"#, + r#""customPosixShm": {"readWrite": "/app"}"#, + ] { + let json = format!( + r#"{{ + "version": "1.1.0-alpha", + "seatbelt": {{{body}}}, + "process": {{"commandLine": "echo"}} + }}"# + ); + + assert_invalid(&json); + } +} diff --git a/tests/configs/seatbelt_capture_denials_gui_app_custom_allow.json b/tests/configs/seatbelt_capture_denials_gui_app_custom_allow.json new file mode 100644 index 0000000000..62c5259c81 --- /dev/null +++ b/tests/configs/seatbelt_capture_denials_gui_app_custom_allow.json @@ -0,0 +1,31 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "containment": "seatbelt", + "process": { + "commandLine": "/usr/bin/osascript -l JavaScript {{TESTDIR}}/app/window.js", + "timeout": 30000 + }, + "filesystem": { + "readonlyPaths": [ + "{{TESTDIR}}/app" + ] + }, + "ui": { + "disable": false + }, + "seatbelt": { + "guiAccess": true, + "customMachLookups": {}, + "customMachRegisters": { + "global": {}, + "local": {} + }, + "customIoKits": {}, + "customPosixShm": {}, + "captureDenials": { + "mode": "allow", + "outputPath": "{{OUTPUT}}" + } + } +} diff --git a/tests/configs/seatbelt_gui_access_custom_clipboard_none.json b/tests/configs/seatbelt_gui_access_custom_clipboard_none.json new file mode 100644 index 0000000000..866f98f101 --- /dev/null +++ b/tests/configs/seatbelt_gui_access_custom_clipboard_none.json @@ -0,0 +1,22 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "containment": "seatbelt", + "process": { + "commandLine": "pbpaste > /dev/null 2>&1 && echo CLIPBOARD_OK || echo CLIPBOARD_BLOCKED", + "timeout": 20000 + }, + "ui": { + "disable": false, + "clipboard": "none" + }, + "seatbelt": { + "guiAccess": true, + "customMachLookups": { + "default": "allow", + "allow": [ + "com.apple.*" + ] + } + } +} diff --git a/tests/configs/seatbelt_gui_access_custom_lookups_allow.json b/tests/configs/seatbelt_gui_access_custom_lookups_allow.json new file mode 100644 index 0000000000..1d43843b7a --- /dev/null +++ b/tests/configs/seatbelt_gui_access_custom_lookups_allow.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "containment": "seatbelt", + "process": { + "commandLine": "id -un || true", + "timeout": 20000 + }, + "ui": { + "disable": false + }, + "seatbelt": { + "guiAccess": true, + "customMachLookups": { + "default": "deny", + "allow": [ + "com.apple.system.opendirectoryd.*" + ] + } + } +} diff --git a/tests/configs/seatbelt_gui_access_custom_lookups_deny.json b/tests/configs/seatbelt_gui_access_custom_lookups_deny.json new file mode 100644 index 0000000000..2237b62b48 --- /dev/null +++ b/tests/configs/seatbelt_gui_access_custom_lookups_deny.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "containment": "seatbelt", + "process": { + "commandLine": "id -un || true", + "timeout": 20000 + }, + "ui": { + "disable": false + }, + "seatbelt": { + "guiAccess": true, + "customMachLookups": { + "default": "allow", + "deny": [ + "com.apple.system.opendirectoryd.*" + ] + } + } +} diff --git a/tests/configs/seatbelt_gui_access_custom_posix_shm.json b/tests/configs/seatbelt_gui_access_custom_posix_shm.json new file mode 100644 index 0000000000..1237ee52db --- /dev/null +++ b/tests/configs/seatbelt_gui_access_custom_posix_shm.json @@ -0,0 +1,36 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "containment": "seatbelt", + "process": { + "commandLine": "/usr/bin/python3 {{TESTDIR}}/shm_probe.py {{SHM}} /dev/null 2>&1 && echo KEYCHAIN_OK || echo KEYCHAIN_BLOCKED", + "timeout": 20000, + "env": [ + "HOME={{HOME}}" + ] + }, + "ui": { + "disable": false + }, + "seatbelt": { + "guiAccess": true + } +} diff --git a/tests/configs/seatbelt_gui_access_nested_pty_off.json b/tests/configs/seatbelt_gui_access_nested_pty_off.json new file mode 100644 index 0000000000..dc75bf39c8 --- /dev/null +++ b/tests/configs/seatbelt_gui_access_nested_pty_off.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../schemas/stable/mxc-config.schema.1.0.0.json", + "version": "1.0.0", + "containment": "seatbelt", + "process": { + "commandLine": "python3 -c \"import pty;pty.openpty();print('PTY_OK')\" /dev/null || echo PTY_BLOCKED", + "timeout": 20000 + }, + "filesystem": { + "readonlyPaths": [ + "{{DEVDIR}}" + ] + }, + "ui": { + "disable": false + }, + "seatbelt": { + "guiAccess": true, + "nestedPty": false + } +} diff --git a/tests/configs/seatbelt_gui_access_readonly_tmp.json b/tests/configs/seatbelt_gui_access_readonly_tmp.json new file mode 100644 index 0000000000..15eb569baa --- /dev/null +++ b/tests/configs/seatbelt_gui_access_readonly_tmp.json @@ -0,0 +1,20 @@ +{ + "$schema": "../../schemas/stable/mxc-config.schema.1.0.0.json", + "version": "1.0.0", + "containment": "seatbelt", + "process": { + "commandLine": "cat {{TESTDIR}}/ro/file.txt; (echo changed > {{TESTDIR}}/ro/file.txt) 2>/dev/null && echo TMP_WRITE_OK || echo TMP_WRITE_BLOCKED", + "timeout": 20000 + }, + "filesystem": { + "readonlyPaths": [ + "{{TESTDIR}}/ro" + ] + }, + "ui": { + "disable": false + }, + "seatbelt": { + "guiAccess": true + } +} diff --git a/tests/scripts/run_seatbelt_capture_denials_test.sh b/tests/scripts/run_seatbelt_capture_denials_test.sh index 38e31c6c2f..dbb78cfbd3 100755 --- a/tests/scripts/run_seatbelt_capture_denials_test.sh +++ b/tests/scripts/run_seatbelt_capture_denials_test.sh @@ -592,6 +592,16 @@ for resource in "$GUI_MACH" "$GUI_IOKIT"; do done pass "granted requests are not listed as denials" +# In allow mode, empty custom lists don't block the app; the report names what +# the lists need to grant. +run_config "$(render seatbelt_capture_denials_gui_app_custom_allow.json TESTDIR "$TESTDIR" OUTPUT "$TESTDIR/out/gui_custom.json")" +expect_ok "allow mode runs the GUI app despite empty custom lists" "GUI_WINDOW_OK" +read_pointer "the allow-mode custom-list run writes a report" +expect_listed "allow mode lists the Core Animation lookup to grant" other "$GUI_MACH" unknown +expect_listed "allow mode lists the IOSurface driver to grant" other "$GUI_IOKIT" unknown +expect_listed "allow mode lists the shared memory to grant" other \ + "ipc-posix-shm-read-data:apple.shm.notification_center" read + # --- rejections -------------------------------------------------------------- expect_rejected "captureDenials cannot be combined with profileOverride" \ diff --git a/tests/scripts/run_seatbelt_gui_access_test.sh b/tests/scripts/run_seatbelt_gui_access_test.sh index 8d8ed4dc05..95d6419abc 100755 --- a/tests/scripts/run_seatbelt_gui_access_test.sh +++ b/tests/scripts/run_seatbelt_gui_access_test.sh @@ -3,15 +3,17 @@ # # guiAccess deliberately trades precision for breadth: rather than maintain a # fragile allowlist of the Mach services window drawing needs, it emits a -# blanket `(allow mach-lookup)` plus `mach-register`, `iokit-open`, -# `pseudo-tty` and POSIX shared memory. That breadth is the reason it needs -# testing in both directions -- it must widen what it claims to widen, and it -# must NOT silently re-open the things UI policy separately denies. +# blanket `(allow mach-lookup)` plus `mach-register`, `iokit-open`, POSIX +# shared memory, and per-user temp/cache writes. That breadth is the reason it +# needs testing in both directions -- it must widen what it claims to widen, +# and it must NOT re-open what an explicit setting (ui.clipboard, ui.injection, +# keychainAccess, nestedPty, filesystem rules) decides. The custom* blocks +# replace the blanket grants with explicit lists. # # The deny rules are emitted after the blanket allows and Seatbelt takes the # last matching rule, so their survival is purely an ordering property: a -# refactor that hoisted the guiAccess block would defeat ui.clipboard and -# ui.injection without failing any single-config assertion. +# refactor that hoisted the guiAccess block would defeat those settings +# without failing any single-config assertion. set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -34,6 +36,89 @@ expect_absent "without guiAccess opendirectoryd is unreachable" "$(id -un)" run_config "$(render seatbelt_gui_access_on.json)" expect_ok "guiAccess grants blanket Mach lookup" "$(id -un)" +# --- customMachLookups replaces the blanket lookup grant ---------------------- + +run_config "$(render seatbelt_gui_access_custom_lookups_allow.json)" +expect_ok "customMachLookups.allow reaches opendirectoryd by prefix" "$(id -un)" + +run_config "$(render seatbelt_gui_access_custom_lookups_deny.json)" +expect_ok "the default-allow customMachLookups run started" "$(id -u)" +expect_absent "customMachLookups.deny blocks opendirectoryd" "$(id -un)" + +# --- explicit settings win over guiAccess -------------------------------------- + +run_config "$(render seatbelt_gui_access_keychain_off.json HOME "$HOME")" +expect_ok "keychainAccess=false blocks the Keychain under guiAccess" "KEYCHAIN_BLOCKED" + +require_python3_probe +run_config "$(render seatbelt_gui_access_nested_pty_off.json)" +expect_ok "nestedPty=false blocks pty allocation under guiAccess" "PTY_BLOCKED" + +TESTDIR="$(mktemp -d /private/tmp/mxc-seatbelt-gui.XXXXXX)" +# POSIX shared memory names are limited to 31 bytes. +SHM="/mxc$$" +shm_cleanup() { + /usr/bin/python3 -c ' +import _posixshmem as s, sys +for n in sys.argv[2:]: + try: s.shm_unlink(sys.argv[1] + n) + except OSError: pass' "$SHM" .ro .rw .c.a .c.no .other +} +trap 'shm_cleanup; rm -rf "$TESTDIR" "$SEATBELT_TMP"' EXIT +mkdir -p "$TESTDIR/ro" +echo "GUI_TMP_ORIGINAL" >"$TESTDIR/ro/file.txt" +chmod -R a+rX "$TESTDIR" +run_config "$(render seatbelt_gui_access_readonly_tmp.json TESTDIR "$TESTDIR")" +expect_ok "readonlyPaths inside /private/tmp stay readable under guiAccess" "GUI_TMP_ORIGINAL" +expect_ok "readonlyPaths inside /private/tmp stay read-only under guiAccess" "TMP_WRITE_BLOCKED" +grep -qx "GUI_TMP_ORIGINAL" "$TESTDIR/ro/file.txt" || + fail "the read-only file was modified" "$(cat "$TESTDIR/ro/file.txt")" +pass "the read-only file is unchanged" + +# --- customPosixShm replaces the shared memory grant --------------------------- + +cat >"$TESTDIR/shm_probe.py" <<'PROBE' +import _posixshmem as s, os, sys + +prefix = sys.argv[1] + + +def probe(label, name, flags): + try: + os.close(s.shm_open(prefix + name, flags, 0o600)) + print(label + "_OK") + except OSError: + print(label + "_BLOCKED") + + +probe("RO_READ", ".ro", os.O_RDONLY) +probe("RO_WRITE", ".ro", os.O_RDWR) +probe("RW_WRITE", ".rw", os.O_RDWR) +probe("RW_CREAT", ".rw", os.O_RDWR | os.O_CREAT) +probe("CREATE", ".c.a", os.O_RDWR | os.O_CREAT | os.O_EXCL) +probe("DENY", ".c.no", os.O_RDWR | os.O_CREAT | os.O_EXCL) +probe("UNLISTED", ".other", os.O_RDWR | os.O_CREAT | os.O_EXCL) +try: + s.shm_unlink(prefix + ".c.a") + print("UNLINK_OK") +except OSError: + print("UNLINK_BLOCKED") +PROBE +chmod a+r "$TESTDIR/shm_probe.py" +/usr/bin/python3 -c ' +import _posixshmem as s, os, sys +for n in sys.argv[2:]: os.close(s.shm_open(sys.argv[1] + n, os.O_RDWR | os.O_CREAT, 0o600))' "$SHM" .ro .rw || + fail "could not create the host shared memory regions" +run_config "$(render seatbelt_gui_access_custom_posix_shm.json TESTDIR "$TESTDIR" SHM "$SHM")" +expect_ok "customPosixShm.read opens a region read-only" "RO_READ_OK" +expect_ok "customPosixShm.read does not grant write" "RO_WRITE_BLOCKED" +expect_ok "customPosixShm.readWrite opens a region read-write" "RW_WRITE_OK" +expect_ok "customPosixShm.readWrite does not grant create" "RW_CREAT_BLOCKED" +expect_ok "customPosixShm.own creates a region by prefix" "CREATE_OK" +expect_ok "customPosixShm.own deletes the region it names" "UNLINK_OK" +expect_ok "customPosixShm.deny wins over an own prefix" "DENY_BLOCKED" +expect_ok "customPosixShm replaces guiAccess's shared memory grant" "UNLISTED_BLOCKED" + # --- ordering: UI denies must survive the blanket allow ---------------------- run_config "$(render seatbelt_gui_access_clipboard_none.json)" @@ -42,11 +127,14 @@ expect_ok "ui.clipboard=none still blocks the pasteboard under guiAccess" "CLIPB run_config "$(render seatbelt_gui_access_clipboard_all.json)" expect_ok "ui.clipboard=all permits the pasteboard under guiAccess" "CLIPBOARD_OK" +run_config "$(render seatbelt_gui_access_custom_clipboard_none.json)" +expect_ok "ui.clipboard=none blocks the pasteboard over a broad customMachLookups allow" "CLIPBOARD_BLOCKED" + # --- profile: the documented rules are present and correctly ordered --------- run_config "$(render seatbelt_gui_access_on.json)" --debug P="$(extract "$OUT")" -for rule in "(allow mach-lookup)" "(allow mach-register)" "(allow iokit-open)" "(allow pseudo-tty)"; do +for rule in "(allow mach-lookup)" "(allow mach-register)" "(allow iokit-open)"; do grep -qF "$rule" <<<"$P" || fail "guiAccess emits $rule" "$P" pass "guiAccess emits $rule" done @@ -61,6 +149,28 @@ for rule in "(allow mach-lookup)" "(allow mach-register)" "(allow iokit-open)"; done pass "the blanket GUI allows are absent without guiAccess" +# Each custom block replaces only its own grant; registers split by scope. +run_config "$(render seatbelt_gui_access_custom_registers.json)" --debug +P_CUSTOM="$(extract "$OUT")" +for rule in "(allow mach-lookup)" \ + '(allow iokit-open (iokit-user-client-class "AGXDeviceUserClient")' \ + '(iokit-user-client-class-regex #"^IOSurface")' \ + '(deny iokit-open' \ + '(global-name-prefix "com.example.mxc.")' \ + '(allow mach-register (local-name-prefix ""))'; do + grep -qF "$rule" <<<"$(tr '\n' ' ' <<<"$P_CUSTOM" | sed 's/ */ /g')" || + fail "custom blocks emit $rule" "$P_CUSTOM" +done +for rule in "(allow iokit-open)" "(allow mach-register)" '(allow mach-register (global-name-prefix ""))'; do + grep -qF "$rule" <<<"$P_CUSTOM" && fail "custom blocks must replace $rule" "$P_CUSTOM" +done +pass "custom blocks replace only the guiAccess grants they name" + +run_config "$(render seatbelt_gui_access_custom_lookups_allow.json)" --debug +grep -qF "(allow mach-lookup)" <<<"$(extract "$OUT")" && + fail "customMachLookups must replace the blanket lookup grant" "$(extract "$OUT")" +pass "customMachLookups replaces the blanket lookup grant" + # The ordering property itself: the pasteboard deny must come after the blanket # mach-lookup allow, or the deny is dead text. run_config "$(render seatbelt_gui_access_clipboard_none.json)" --debug diff --git a/tests/scripts/run_seatbelt_options_test.sh b/tests/scripts/run_seatbelt_options_test.sh index 2a1d3b3ada..9a3de11fbb 100755 --- a/tests/scripts/run_seatbelt_options_test.sh +++ b/tests/scripts/run_seatbelt_options_test.sh @@ -102,9 +102,13 @@ pass "keychainAccess grants the user keychain directory" run_config "$(render seatbelt_opt_keychain_off.json HOME "$HOME")" --debug KC_OFF="$(extract "$OUT")" -grep -qF 'com.apple.SecurityServer' <<<"$KC_OFF" && - fail "the Keychain services must be absent without keychainAccess" "$KC_OFF" +grep -qF 'keychainAccess: ' <<<"$KC_OFF" && + fail "the Keychain grants must be absent without keychainAccess" "$KC_OFF" pass "the Keychain grants are absent without keychainAccess" +DENY="$(tr '\n' ' ' <<<"$KC_OFF" | sed 's/ */ /g')" +grep -qF '(deny mach-lookup (global-name "com.apple.SecurityServer") (global-name-prefix "com.apple.securityd")' <<<"$DENY" || + fail "the Keychain servers are denied without keychainAccess" "$KC_OFF" +pass "the Keychain servers are denied without keychainAccess" # extraMachLookups is passed through verbatim, so every listed service must # appear and nothing else may be added alongside them. diff --git a/tests/scripts/run_seatbelt_tty_test.sh b/tests/scripts/run_seatbelt_tty_test.sh index ab99ec794e..1a172c4e36 100755 --- a/tests/scripts/run_seatbelt_tty_test.sh +++ b/tests/scripts/run_seatbelt_tty_test.sh @@ -89,7 +89,7 @@ with contextlib.ExitStack() as cleanup: ("explicit filesystem grant", None, "allow", "allow"), ("default policy", None, "deny", "allow"), ("nestedPty=false", {"nestedPty": False}, "deny", "deny"), - ("guiAccess", {"guiAccess": True, "nestedPty": False}, "deny", "allow"), + ("guiAccess", {"guiAccess": True, "nestedPty": False}, "deny", "deny"), ] for inherited in (False, True): for label, options, access, nested in cases: From 9421455d237021e186f7b6b2a4e707d6867f2b0f Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:58:00 -0800 Subject: [PATCH 2/7] more tests + learning mode now correctly reports keychain/clipboard logs. --- docs/backends/seatbelt/seatbelt-backend.md | 4 +- .../1591-seatbelt-learning-mode/README.md | 4 +- docs/schema.md | 5 +- .../seatbelt/common/profile_builder.rs | 583 +++++++++++++++++- .../seatbelt/common/seatbelt_policy.rs | 70 ++- .../src/core/mxc_common/config_parser.rs | 71 ++- src/mxc-sdk/src/core/mxc_common/models.rs | 19 + .../tests/mxc_contract/v1_0_0/seatbelt.rs | 20 + ...seatbelt_capture_denials_gui_keychain.json | 22 + .../seatbelt_gui_access_custom_all.json | 85 +++ .../run_seatbelt_capture_denials_test.sh | 55 +- tests/scripts/run_seatbelt_gui_access_test.sh | 248 +++++++- 12 files changed, 1127 insertions(+), 59 deletions(-) create mode 100644 tests/configs/seatbelt_capture_denials_gui_keychain.json create mode 100644 tests/configs/seatbelt_gui_access_custom_all.json diff --git a/docs/backends/seatbelt/seatbelt-backend.md b/docs/backends/seatbelt/seatbelt-backend.md index abc2e049b5..51f69e8f43 100644 --- a/docs/backends/seatbelt/seatbelt-backend.md +++ b/docs/backends/seatbelt/seatbelt-backend.md @@ -419,7 +419,7 @@ the V1.Dev JSON API for [Rust](../../api-reference/rust/v1/dev.md), | Field | Default | Meaning | |---|---|---| -| `mode` | `"block"` | `"block"`: blocked accesses stay blocked and are recorded. `"allow"`: accesses the policy would block by default are allowed and recorded, and MXC prints a warning. Explicit denies, such as `deniedPaths` and `ui` denies, are still enforced. | +| `mode` | `"block"` | `"block"`: blocked accesses stay blocked and are recorded. `"allow"`: accesses the policy would block by default are allowed and recorded, and MXC prints a warning. Settings left out of the config, such as `keychainAccess` or `ui.clipboard`, also don't block. `deniedPaths`, custom `deny` entries, and settings written in the config are still enforced. | | `outputPath` | a temp file | Absolute path, or one starting with `~/`. The parent directory must exist. | | `retainEtl` | `false` | Keeps the raw `log stream` output next to the report as `.seatbelt.log`, readable only by you. The pointer line's `etlPath` names it. The field shares its name with Windows; the file isn't an ETL. | @@ -609,7 +609,7 @@ MXC rejects a config when: To find the names an app needs, run it once with [`captureDenials`](#learning-mode-capturedenials) in `"allow"` mode. In that mode every custom list acts as `default: "allow"`, so the app isn't blocked, -while `deny` entries and setting denies still apply. The report lists each +while `deny` entries and settings written in the config still apply. The report lists each name the lists don't grant, such as `mach-lookup:com.apple.CARenderServer` or `ipc-posix-shm-read-data:apple.shm.notification_center`. Add those names to the matching list. diff --git a/docs/development/architecture/designs/1591-seatbelt-learning-mode/README.md b/docs/development/architecture/designs/1591-seatbelt-learning-mode/README.md index a73f3daff3..9d1ae58511 100644 --- a/docs/development/architecture/designs/1591-seatbelt-learning-mode/README.md +++ b/docs/development/architecture/designs/1591-seatbelt-learning-mode/README.md @@ -61,7 +61,7 @@ temp report afterward. | Field | Default | Behavior | |---|---|---| -| `mode` | `"block"` | `block`: denied accesses stay denied and are recorded. `allow`: accesses the default policy would deny are allowed and recorded. Explicit denies (`deniedPaths`, `ui` denies) stay enforced. MXC prints a warning. | +| `mode` | `"block"` | `block`: denied accesses stay denied and are recorded. `allow`: accesses the default policy would deny are allowed and recorded. Settings the request omits don't block either. `deniedPaths` and written settings stay enforced. MXC prints a warning. | | `outputPath` | Temp file | Absolute or `~/` path. The parent directory must exist. Empty, relative, root, and directory paths are rejected. A name without an extension gets `.json`. | | `retainEtl` | `false` | Keep the run's filtered raw log as `.seatbelt.log` next to the report, and return its path in `etlPath`. The Windows name is reused; see [compatibility](#contracts-apis-and-compatibility). | @@ -189,7 +189,7 @@ capture after parsing. | Component | Change | |---|---| | `mxc_contract::dev` | New Seatbelt field | -| Seatbelt `profile_builder` | Adds a per-run tag to deny rules. `allow` mode uses a reporting allow baseline and keeps explicit denies. | +| Seatbelt `profile_builder` | Adds a per-run tag to deny rules. `allow` mode uses a reporting allow baseline, reports omitted settings' denies, and keeps written ones. | | Seatbelt `denial_capture` | Runs `log stream` filtered on the tag, and uses ready and drain markers. Enforces the limits above. | | Seatbelt `capture_analysis` | Converts log events into shared report records. Uses the Windows fast path once verbose groups fill up. | | `learning_mode_core` | Shared report writer, moved from `process_container` | diff --git a/docs/schema.md b/docs/schema.md index c1e25acb4b..83d7134351 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -315,8 +315,9 @@ that can be executed independently. }, "captureDenials": { // 1.1.0-alpha: record what the sandbox blocked. // Not combinable with profileOverride. - "mode": "block", // "block" (default) or "allow" (default-denied access is - // allowed and recorded; explicit denies still apply) + "mode": "block", // "block" (default) or "allow" (default-denied access and + // omitted settings are allowed and recorded; deniedPaths, + // deny lists, and written settings still apply) "outputPath": "~/denials.json",// Absolute or ~/ path; parent dir must exist. Omit for a // temp file. Actual path is printed on stderr. "retainEtl": false // Keep the raw log as a .seatbelt.log and report its diff --git a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs index 5056724636..02adf9c617 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs @@ -28,7 +28,8 @@ //! With `seatbelt.captureDenials`, every deny rule carries the run's //! `(with message "")` so its log entries can be attributed to this run. //! Allow mode swaps `(deny default)` for `(allow default (with report) ...)`, -//! and the broad guiAccess grants report under `:grant`. +//! reports instead of enforcing the denies of settings the request omitted, +//! and makes the broad guiAccess grants report under `:grant`. use crate::mxc_common::filesystem_resolve::{resolve_path_plan, FsIntent}; use crate::mxc_common::host_is_canonical_loopback; @@ -196,9 +197,9 @@ pub(crate) fn build_profile_with_terminals( write_extra_seatbelt_rules(&mut out, request); write_custom_allows(&mut out, request, capture.as_ref()); - // Denies from explicit settings follow every allow so no grant reopens them. - write_ui_policy_denies(&mut out, request); - write_keychain_denies(&mut out, request); + // Setting denies follow every allow so no grant reopens them. + write_ui_policy_denies(&mut out, request, capture.as_ref()); + write_keychain_denies(&mut out, request, capture.as_ref()); write_custom_denies(&mut out, request); // Policy-derived deny rules go LAST so they win on conflict. @@ -617,28 +618,85 @@ fn write_global_names(out: &mut String, action: &str, names: &[&str]) { out.push_str(")\n"); } +/// Opens the deny rule a setting adds for `operation`; the caller writes the +/// filters and closes it. In allow mode, a +/// setting the request omitted allows and reports each use instead, so the +/// report lists it even where a broader grant such as guiAccess covers it. +fn open_setting_rule( + out: &mut String, + field: &str, + comment: &str, + operation: &str, + written: bool, + capture: Option<&CaptureRules>, +) { + match capture.filter(|rules| rules.allow_mode && !written) { + Some(rules) => { + let _ = writeln!(out, ";; --- {field} omitted: allow and report ---"); + let _ = write!( + out, + "(allow {operation} (with report) (with message {})", + quote_scheme(rules.tag) + ); + } + None => { + let _ = writeln!(out, ";; --- {comment} ---"); + let _ = write!(out, "(deny {operation}"); + } + } +} + /// UI settings that remove access. Emitted after every allow rule. -fn write_ui_policy_denies(out: &mut String, request: &ExecutionRequest) { +fn write_ui_policy_denies( + out: &mut String, + request: &ExecutionRequest, + capture: Option<&CaptureRules>, +) { let ui = &request.policy.ui; + let written = request.policy.ui_fields_specified; if ui.disable { - out.push_str(";; --- ui.disable: deny WindowServer + related ---\n"); - write_global_names(out, "deny", seatbelt_policy::UI_SERVICE_NAMES); + open_setting_rule( + out, + "ui.disable", + "ui.disable: deny WindowServer + related", + "mach-lookup", + written.disable, + capture, + ); + for name in seatbelt_policy::UI_SERVICE_NAMES { + let _ = write!(out, "\n (global-name {})", quote_scheme(name)); + } + out.push_str("\n)\n"); } if matches!(ui.clipboard, ClipboardPolicy::None) { - out.push_str(";; --- ui.clipboard=none: deny pasteboard servers ---\n"); + open_setting_rule( + out, + "ui.clipboard", + "ui.clipboard=none: deny pasteboard servers", + "mach-lookup", + written.clipboard, + capture, + ); let _ = writeln!( out, - "(deny mach-lookup (global-name-prefix {}))", + " (global-name-prefix {}))", quote_scheme(seatbelt_policy::PASTEBOARD_SERVICE_PREFIX) ); } if !ui.injection { - out.push_str(";; --- ui.injection=false: deny HID iokit access ---\n"); + open_setting_rule( + out, + "ui.injection", + "ui.injection=false: deny HID iokit access", + "iokit-open", + written.injection, + capture, + ); let _ = writeln!( out, - "(deny iokit-open (iokit-user-client-class {}))", + " (iokit-user-client-class {}))", quote_scheme(seatbelt_policy::HID_USER_CLIENT_CLASS) ); } @@ -730,19 +788,30 @@ fn write_keychain_rules( /// Without `keychainAccess`, deny the Keychain servers after every allow so /// that no broader Mach grant reaches them. -fn write_keychain_denies(out: &mut String, request: &ExecutionRequest) { - if request.seatbelt.as_ref().is_some_and(|c| c.keychain_access) { +fn write_keychain_denies( + out: &mut String, + request: &ExecutionRequest, + capture: Option<&CaptureRules>, +) { + let seatbelt = request.seatbelt.as_ref(); + if seatbelt.is_some_and(|c| c.keychain_access) { return; } - out.push_str(";; --- keychainAccess=false: deny Keychain servers ---\n"); - out.push_str("(deny mach-lookup\n"); + open_setting_rule( + out, + "keychainAccess", + "keychainAccess=false: deny Keychain servers", + "mach-lookup", + seatbelt.is_some_and(|c| c.keychain_access_specified), + capture, + ); for name in seatbelt_policy::KEYCHAIN_SERVER_NAMES { - let _ = writeln!(out, " (global-name {})", quote_scheme(name)); + let _ = write!(out, "\n (global-name {})", quote_scheme(name)); } for prefix in seatbelt_policy::KEYCHAIN_SERVER_PREFIXES { - let _ = writeln!(out, " (global-name-prefix {})", quote_scheme(prefix)); + let _ = write!(out, "\n (global-name-prefix {})", quote_scheme(prefix)); } - out.push_str(")\n"); + out.push_str("\n)\n"); } /// Emit caller-provided `extraMachLookups` rules: additional Mach service @@ -824,7 +893,13 @@ const SHM_READ_WRITE_TARGET: CustomRuleTarget = posix_shm_target( ); // Also covers create and delete. const SHM_OWN_TARGET: CustomRuleTarget = posix_shm_target("customPosixShm.own", "ipc-posix-shm*"); -const SHM_DENY_TARGET: CustomRuleTarget = posix_shm_target("customPosixShm.deny", "ipc-posix-shm*"); +// Seatbelt prefers a rule that names an operation over a wildcard, so the +// deny names each one to win over `default: "allow"` and `readWrite`. +const SHM_DENY_TARGET: CustomRuleTarget = posix_shm_target( + "customPosixShm.deny", + "ipc-posix-shm* ipc-posix-shm-read-data ipc-posix-shm-read-metadata \ + ipc-posix-shm-write-data ipc-posix-shm-write-create ipc-posix-shm-write-unlink", +); fn custom_rule_targets( request: &ExecutionRequest, @@ -2189,7 +2264,8 @@ mod tests { #[test] fn capture_allow_mode_reports_instead_of_denying_by_default() { - let p = capture_profile(&capture_request(CaptureDenialsMode::Allow, false)); + let r = capture_request(CaptureDenialsMode::Allow, false); + let p = capture_profile(&r); assert!(p.starts_with( "(version 1)\n(allow default (with report) (with message \"mxc-capture-test\"))\n" )); @@ -2198,6 +2274,29 @@ mod tests { assert!(p.contains( "(deny file-read* file-read-metadata file-write* network-bind network-outbound (with message \"mxc-capture-test\")\n" )); + // Omitted settings allow and report; only written ones are enforced. + let settings = [ + ("ui.disable", "ui.disable: deny"), + ("ui.clipboard", "ui.clipboard=none: deny"), + ("ui.injection", "ui.injection=false: deny"), + ("keychainAccess", "keychainAccess=false: deny"), + ]; + let mut written = capture_request(CaptureDenialsMode::Allow, false); + written.policy.ui_fields_specified.disable = true; + written.seatbelt.as_mut().unwrap().keychain_access_specified = true; + for (request, enforced) in [(&r, [false; 4]), (&written, [true, false, false, true])] { + let p = capture_profile(request); + for ((field, deny), enforced) in settings.into_iter().zip(enforced) { + assert_eq!(p.contains(deny), enforced, "{deny}\n{p}"); + let report = format!(";; --- {field} omitted: allow and report ---\n"); + assert_eq!(p.contains(&report), !enforced, "{field}\n{p}"); + } + } + assert!(capture_profile(&written).contains( + ";; --- ui.clipboard omitted: allow and report ---\n\ + (allow mach-lookup (with report) (with message \"mxc-capture-test\") \ + (global-name-prefix \"com.apple.pasteboard.\"))\n" + )); } #[test] @@ -2339,7 +2438,9 @@ mod tests { "(allow ipc-posix-shm-read* ipc-posix-shm-write-data\n \ (ipc-posix-name \"/example.rw\")\n)", "(allow ipc-posix-shm*\n (ipc-posix-name-prefix \"/example.app.\")\n)", - "(deny ipc-posix-shm*\n (ipc-posix-name \"/example.app.secret\")\n)", + "(deny ipc-posix-shm* ipc-posix-shm-read-data ipc-posix-shm-read-metadata \ + ipc-posix-shm-write-data ipc-posix-shm-write-create ipc-posix-shm-write-unlink\n \ + (ipc-posix-name \"/example.app.secret\")\n)", ] { assert!(p.contains(rule), "{rule}: {p}"); } @@ -2493,4 +2594,444 @@ mod tests { ); } } + + #[cfg(target_os = "macos")] + fn sandbox_exec(profile: &str, program: &[&str]) -> std::process::Output { + std::process::Command::new("/usr/bin/sandbox-exec") + .args(["-p", profile]) + .args(program) + .output() + .expect("sandbox-exec runs") + } + + /// Every custom block, with each default, mixed with every setting + /// (written or omitted) and capture mode: the profile compiles, guiAccess + /// never adds back a grant a block replaced, settings' denies and custom + /// denies follow all custom allows, and allow mode reports rather than + /// enforces the denies of omitted settings. + #[cfg(target_os = "macos")] + #[test] + fn custom_rules_hold_across_setting_combinations() { + let custom_lines = |p: &str, deny: bool| -> Vec { + p.match_indices(";; --- custom") + .filter(|(at, _)| p[*at..].lines().next().unwrap().contains(".deny ") == deny) + .map(|(at, _)| at) + .collect() + }; + let captures = [ + None, + Some(CaptureDenialsMode::Block), + Some(CaptureDenialsMode::Allow), + ]; + let mut combinations = 0; + for written in [false, true] { + for gui_access in [false, true] { + for keychain_access in [false, true] { + for clipboard in [ClipboardPolicy::None, ClipboardPolicy::All] { + for injection in [false, true] { + for capture in captures { + for default in + [SeatbeltCustomDefault::Deny, SeatbeltCustomDefault::Allow] + { + let mut r = custom_request(); + r.policy.ui.clipboard = clipboard.clone(); + r.policy.ui.injection = injection; + r.policy.ui_fields_specified = + crate::mxc_common::models::UiFieldsSpecified { + disable: written, + clipboard: written, + injection: written, + }; + let config = r.seatbelt.as_mut().unwrap(); + config.gui_access = gui_access; + config.keychain_access = keychain_access; + config.keychain_access_specified = written; + config.capture_denials = capture.map(|mode| { + crate::mxc_common::models::CaptureDenialsConfig { + mode, + ..Default::default() + } + }); + config.custom_io_kits.as_mut().unwrap().default = default; + config.custom_mach_lookups.as_mut().unwrap().default = default; + let registers = config.custom_mach_registers.as_mut().unwrap(); + registers.global.as_mut().unwrap().default = default; + registers.local.as_mut().unwrap().default = default; + config.custom_posix_shm.as_mut().unwrap().default = default; + let label = format!( + "written={written} gui={gui_access} keychain={keychain_access} \ + clipboard={clipboard:?} injection={injection} \ + capture={capture:?} default={default:?}" + ); + crate::seatbelt_common::seatbelt_policy::validate_seatbelt_custom_rules(&r) + .unwrap_or_else(|e| panic!("{label}: {e}")); + let inputs = ProfileInputs { + capture_tag: Some("mxc-capture-test".into()), + user_keychains: keychain_access.then(|| { + "/Users/profile-test/Library/Keychains".into() + }), + ..Default::default() + }; + let p = super::build_profile_with_proxy(&r, None, &inputs) + .unwrap_or_else(|e| panic!("{label}: {e}")); + + if default == SeatbeltCustomDefault::Deny { + for op in ["mach-lookup", "iokit-open"] { + for broad in [ + format!("(allow {op})"), + format!( + "(allow {op} (with report) (with message \ + \"mxc-capture-test{GRANT_TAG_SUFFIX}\"))\n" + ), + ] { + assert!( + !p.contains(&broad), + "{label}: {broad}\n{p}" + ); + } + } + assert!( + !p.contains("(allow mach-register ("), + "{label}\n{p}" + ); + assert!( + !p.contains("(allow ipc-posix-shm-read-data"), + "{label}\n{p}" + ); + } + let last_allow = *custom_lines(&p, false).last().unwrap(); + let denies = custom_lines(&p, true); + assert_eq!(denies.len(), 5, "{label}\n{p}"); + assert!(denies[0] > last_allow, "{label}\n{p}"); + let enforced = + written || capture != Some(CaptureDenialsMode::Allow); + for (on, deny, field) in [ + ( + !keychain_access, + "keychainAccess=false: deny", + "keychainAccess", + ), + ( + clipboard == ClipboardPolicy::None, + "ui.clipboard=none", + "ui.clipboard", + ), + (!injection, "ui.injection=false", "ui.injection"), + ] { + let report = format!("{field} omitted: allow and report"); + for (marker, expected) in [ + (deny, on && enforced), + (report.as_str(), on && !enforced), + ] { + let at = p.find(marker); + assert_eq!( + at.is_some(), + expected, + "{label}: {marker}\n{p}" + ); + if let Some(at) = at { + assert!( + last_allow < at && at < denies[0], + "{label}: {marker}\n{p}" + ); + } + } + } + + let output = sandbox_exec(&p, &["/usr/bin/true"]); + assert!( + output.status.success(), + "{label}: {}\n{p}", + String::from_utf8_lossy(&output.stderr) + ); + combinations += 1; + } + } + } + } + } + } + } + assert_eq!(combinations, 192); + } + + /// Quotes, backslashes, parentheses, and newlines in names stay inside + /// their string literal instead of adding rules to the profile. + #[cfg(target_os = "macos")] + #[test] + fn custom_entries_cannot_add_profile_rules() { + let hostile = [ + "x\") (allow default) (\"", + "x\\\") (allow default) ;", + "x)\n(allow default)\n(", + ]; + let names: Vec = hostile.iter().map(|s| s.to_string()).collect(); + let rules = || custom_rules(SeatbeltCustomDefault::Deny, &hostile, &hostile); + let mut r = req(); + r.seatbelt = Some(SeatbeltConfig { + custom_mach_lookups: Some(rules()), + custom_mach_registers: Some(SeatbeltCustomMachRegisters { + global: Some(rules()), + local: Some(rules()), + }), + custom_posix_shm: Some(SeatbeltCustomPosixShm { + read: names.clone(), + read_write: names.clone(), + own: names.clone(), + deny: names, + ..Default::default() + }), + ..Default::default() + }); + crate::seatbelt_common::seatbelt_policy::validate_seatbelt_custom_rules(&r).unwrap(); + let hostile_profile = build_profile(&r).unwrap(); + let control = build_profile(&req()).unwrap(); + + // `id -un` needs a Mach lookup the baseline denies, so it prints the + // numeric uid unless something allowed every operation. + let uid = String::from_utf8( + std::process::Command::new("/usr/bin/id") + .arg("-u") + .output() + .unwrap() + .stdout, + ) + .unwrap(); + for profile in [&control, &hostile_profile] { + let output = sandbox_exec(profile, &["/usr/bin/id", "-un"]); + assert!( + output.status.success(), + "{}\n{profile}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), uid, "{profile}"); + } + } + + fn strings(entries: &[&str]) -> Vec { + entries.iter().map(|s| s.to_string()).collect() + } + + /// Every list of every custom block holds several exact and prefix + /// entries; each one lands, in order, in its own list's rule. + #[test] + fn every_list_emits_each_of_several_entries() { + let mut r = req(); + r.policy.ui.disable = false; + let mach = |scope: &str| { + custom_rules( + SeatbeltCustomDefault::Deny, + &[ + &format!("com.example.{scope}.one"), + &format!("com.example.{scope}.two"), + &format!("com.example.{scope}.app.*"), + &format!("org.other.{scope}.*"), + ], + &[ + &format!("com.example.{scope}.app.no"), + &format!("com.example.{scope}.app.secret.*"), + &format!("org.other.{scope}.no"), + ], + ) + }; + r.seatbelt = Some(SeatbeltConfig { + gui_access: true, + custom_io_kits: Some(custom_rules( + SeatbeltCustomDefault::Deny, + &[ + "AGXDeviceUserClient", + "IOSurfaceRootUserClient", + "IOAccel*", + "AppleM2*", + ], + &["IOAccelBad", "AppleM2Scaler*", "AppleUSB*"], + )), + custom_mach_lookups: Some(mach("lookup")), + custom_mach_registers: Some(SeatbeltCustomMachRegisters { + global: Some(mach("global")), + local: Some(mach("local")), + }), + custom_posix_shm: Some(SeatbeltCustomPosixShm { + read: strings(&["/r.one", "/r.two", "/r.pre.*"]), + read_write: strings(&["/w.one", "/w.two", "/w.pre.*"]), + own: strings(&["/o.one", "/o.two", "/o.pre.*"]), + deny: strings(&["/o.pre.no", "/w.pre.no", "/d.pre.*"]), + ..Default::default() + }), + ..Default::default() + }); + crate::seatbelt_common::seatbelt_policy::validate_seatbelt_custom_rules(&r).unwrap(); + let p = build_profile(&r).unwrap(); + + let mut expected = vec![ + "(allow iokit-open\n (iokit-user-client-class \"AGXDeviceUserClient\")\n \ + (iokit-user-client-class \"IOSurfaceRootUserClient\")\n \ + (iokit-user-client-class-regex #\"^IOAccel\")\n \ + (iokit-user-client-class-regex #\"^AppleM2\")\n)\n" + .to_string(), + "(deny iokit-open\n (iokit-user-client-class \"IOAccelBad\")\n \ + (iokit-user-client-class-regex #\"^AppleM2Scaler\")\n \ + (iokit-user-client-class-regex #\"^AppleUSB\")\n)\n" + .to_string(), + ]; + for (scope, op, exact, prefix) in [ + ("lookup", "mach-lookup", "global-name", "global-name-prefix"), + ( + "global", + "mach-register", + "global-name", + "global-name-prefix", + ), + ("local", "mach-register", "local-name", "local-name-prefix"), + ] { + expected.push(format!( + "(allow {op}\n ({exact} \"com.example.{scope}.one\")\n \ + ({exact} \"com.example.{scope}.two\")\n \ + ({prefix} \"com.example.{scope}.app.\")\n \ + ({prefix} \"org.other.{scope}.\")\n)\n" + )); + expected.push(format!( + "(deny {op}\n ({exact} \"com.example.{scope}.app.no\")\n \ + ({prefix} \"com.example.{scope}.app.secret.\")\n \ + ({exact} \"org.other.{scope}.no\")\n)\n" + )); + } + for (action, ops, list) in [ + ( + "allow", + SHM_READ_TARGET.operation, + ["/r.one", "/r.two", "/r.pre."], + ), + ( + "allow", + SHM_READ_WRITE_TARGET.operation, + ["/w.one", "/w.two", "/w.pre."], + ), + ( + "allow", + SHM_OWN_TARGET.operation, + ["/o.one", "/o.two", "/o.pre."], + ), + ( + "deny", + SHM_DENY_TARGET.operation, + ["/o.pre.no", "/w.pre.no", "/d.pre."], + ), + ] { + let [one, two, pre] = list; + expected.push(format!( + "({action} {ops}\n (ipc-posix-name \"{one}\")\n (ipc-posix-name \"{two}\")\n \ + (ipc-posix-name-prefix \"{pre}\")\n)\n" + )); + } + let mut last_allow = 0; + let mut first_deny = usize::MAX; + for rule in &expected { + assert_eq!(p.matches(rule.as_str()).count(), 1, "{rule}\n{p}"); + let at = p.find(rule.as_str()).unwrap(); + if rule.starts_with("(allow") { + last_allow = last_allow.max(at); + } else { + first_deny = first_deny.min(at); + } + } + assert!(last_allow < first_deny, "{p}"); + + #[cfg(target_os = "macos")] + { + let output = sandbox_exec(&p, &["/usr/bin/true"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + + /// Duplicates, nested prefixes, a name in both allow and deny, and one + /// shared memory name in every list are accepted as written. Allows + /// add up, and each deny rule follows every allow so it wins. + #[test] + fn overlapping_and_duplicate_entries_are_kept_and_deny_comes_last() { + let overlap = || { + custom_rules( + SeatbeltCustomDefault::Deny, + &[ + "com.example.a", + "com.example.a", + "com.example.*", + "com.example.a.*", + ], + &["com.example.a", "com.example.a", "com.example.a.b.*"], + ) + }; + let mut r = req(); + r.policy.ui.disable = false; + r.seatbelt = Some(SeatbeltConfig { + gui_access: true, + custom_io_kits: Some(custom_rules( + SeatbeltCustomDefault::Allow, + &[ + "IOSurface*", + "IOSurfaceRootUserClient", + "IOSurfaceRootUserClient", + ], + &["IOSurfaceRootUserClient", "IOSurface*"], + )), + custom_mach_lookups: Some(overlap()), + custom_mach_registers: Some(SeatbeltCustomMachRegisters { + global: Some(overlap()), + local: Some(overlap()), + }), + custom_posix_shm: Some(SeatbeltCustomPosixShm { + default: SeatbeltCustomDefault::Allow, + read: strings(&["/x.a", "/x.a", "/x.*"]), + read_write: strings(&["/x.a", "/x.*"]), + own: strings(&["/x.a", "/x.*"]), + deny: strings(&["/x.a", "/x.a"]), + }), + ..Default::default() + }); + crate::seatbelt_common::seatbelt_policy::validate_seatbelt_custom_rules(&r).unwrap(); + let p = build_profile(&r).unwrap(); + + let allows: Vec = p.match_indices("\n(allow ").map(|(at, _)| at).collect(); + let denies: Vec = p + .match_indices(";; --- custom") + .filter(|(at, _)| p[*at..].lines().next().unwrap().contains(".deny ")) + .map(|(at, _)| at) + .collect(); + assert_eq!(denies.len(), 5, "{p}"); + assert!(allows.last().unwrap() < &denies[0], "{p}"); + // Duplicates are emitted as written; Seatbelt treats them as one. + assert_eq!( + p.matches("(global-name \"com.example.a\")").count(), + 2 * 2 * 2, + "{p}" + ); + assert_eq!( + p.matches("(local-name \"com.example.a\")").count(), + 2 * 2, + "{p}" + ); + assert_eq!( + p.matches("(ipc-posix-name \"/x.a\")").count(), + 2 + 1 + 1 + 2, + "{p}" + ); + assert!(p.contains( + "(deny iokit-open\n (iokit-user-client-class \"IOSurfaceRootUserClient\")\n \ + (iokit-user-client-class-regex #\"^IOSurface\")\n)\n" + )); + + #[cfg(target_os = "macos")] + { + let output = sandbox_exec(&p, &["/usr/bin/true"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + } } diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs index d1ad047206..88cab7978c 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs @@ -514,7 +514,8 @@ pub fn validate_seatbelt_capture_denials(request: &ExecutionRequest) -> Result<( mod tests { use super::*; use crate::mxc_common::models::{ - ProxyAddress, ProxyConfig, SeatbeltConfig, SeatbeltCustomPosixShm, + ProxyAddress, ProxyConfig, SeatbeltConfig, SeatbeltCustomMachRegisters, + SeatbeltCustomPosixShm, }; #[test] @@ -714,21 +715,66 @@ mod tests { assert!(msg.contains("customIoKits.deny"), "{entry}: {msg}"); assert!(msg.contains("IOKit user-client class"), "{entry}: {msg}"); } - let shm = |own: &str| SeatbeltConfig { - custom_posix_shm: Some(SeatbeltCustomPosixShm { - own: vec![own.into()], + for (scope, entry) in [("global", "*"), ("local", "com.*.x")] { + let rules = Some(SeatbeltCustomRules { + allow: vec![entry.into()], ..Default::default() - }), - ..Default::default() + }); + let registers = if scope == "global" { + SeatbeltCustomMachRegisters { + global: rules, + local: None, + } + } else { + SeatbeltCustomMachRegisters { + global: None, + local: rules, + } + }; + let msg = custom_error(SeatbeltConfig { + custom_mach_registers: Some(registers), + ..Default::default() + }); + let list = format!("customMachRegisters.{scope}.allow"); + assert!(msg.contains(&list), "{entry}: {msg}"); + } + + let shm = |list: &str, entry: &str| { + let mut shm = SeatbeltCustomPosixShm::default(); + let target = match list { + "read" => &mut shm.read, + "readWrite" => &mut shm.read_write, + "own" => &mut shm.own, + _ => &mut shm.deny, + }; + target.push(entry.into()); + seatbelt_request(SeatbeltConfig { + custom_posix_shm: Some(shm), + ..Default::default() + }) }; + // The limit counts bytes, not characters; a trailing '*' is not part + // of the name. let longest = format!("/{}", "a".repeat(30)); - assert!(validate_seatbelt_custom_rules(&seatbelt_request(shm(&longest))).is_ok()); - for (entry, expected) in [ - (format!("{longest}b"), "longer than 31 bytes"), - ("*".to_string(), "bare '*'"), + let multibyte = format!("/{}", "\u{e9}".repeat(15)); + for entry in [longest.clone(), format!("{longest}*"), multibyte.clone()] { + validate_seatbelt_custom_rules(&shm("own", &entry)).unwrap(); + } + for (list, entry, expected) in [ + ("read", format!("{longest}b"), "longer than 31 bytes"), + ( + "readWrite", + format!("{multibyte}\u{e9}"), + "longer than 31 bytes", + ), + ("own", "*".to_string(), "bare '*'"), + ("deny", "/app*.x".to_string(), "not at the end"), ] { - let msg = custom_error(shm(&entry)); - assert!(msg.contains("customPosixShm.own"), "{entry}: {msg}"); + let msg = validate_seatbelt_custom_rules(&shm(list, &entry)).unwrap_err(); + assert!( + msg.contains(&format!("customPosixShm.{list}")), + "{entry}: {msg}" + ); assert!(msg.contains(expected), "{entry}: {msg}"); } } diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index 15d9c06089..ff9782ad57 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -10,8 +10,8 @@ use crate::mxc_common::models::{ CaptureDenialsConfig, CaptureDenialsMode, ContainerPolicy, ContainmentBackend, ExecutionRequest, HyperlightConfig, LifecycleConfig, LxcConfig, PortMapping, SeatbeltConfig, SeatbeltCustomDefault, SeatbeltCustomMachRegisters, SeatbeltCustomPosixShm, - SeatbeltCustomRules, TelemetryConfig, TestFeatureConfig, UiPolicy, WindowsSandboxConfig, - WslcConfig, + SeatbeltCustomRules, TelemetryConfig, TestFeatureConfig, UiFieldsSpecified, UiPolicy, + WindowsSandboxConfig, WslcConfig, }; use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::network_parser::{parse_network_policy, NetworkSections}; @@ -1300,6 +1300,7 @@ fn make_seatbelt_config(sb: wire::Seatbelt) -> SeatbeltConfig { gui_access: gui_access.unwrap_or(false), nested_pty: nested_pty.unwrap_or(true), keychain_access: keychain_access.unwrap_or(false), + keychain_access_specified: keychain_access.is_some(), extra_mach_lookups: extra_mach_lookups.unwrap_or_default(), custom_io_kits: custom_io_kits.map(make_seatbelt_custom_rules), custom_mach_registers: custom_mach_registers.map( @@ -1730,6 +1731,11 @@ fn normalize_common_request_ir( // "caller said nothing". Twin of `network_specified`. policy.ui_specified = cfg.ui.is_some(); if let Some(raw_ui) = cfg.ui { + policy.ui_fields_specified = UiFieldsSpecified { + disable: raw_ui.disable.is_some(), + clipboard: raw_ui.clipboard.is_some(), + injection: raw_ui.injection.is_some(), + }; let clipboard = raw_ui.clipboard.map(Into::into).unwrap_or_default(); policy.ui = UiPolicy { disable: raw_ui.disable.unwrap_or(true), @@ -2028,6 +2034,8 @@ mod tests { network_mode_specified: bool, runtime_network_proxy_specified: bool, ui_specified: bool, + ui_fields_specified: UiFieldsSpecified, + keychain_access_specified: Option, requested_sandbox_kind: Option<&'static str>, } @@ -2048,6 +2056,11 @@ mod tests { network_mode_specified: request.policy.network_mode_specified, runtime_network_proxy_specified: request.policy.runtime_network_proxy_specified, ui_specified: request.policy.ui_specified, + ui_fields_specified: request.policy.ui_fields_specified, + keychain_access_specified: request + .seatbelt + .as_ref() + .map(|seatbelt| seatbelt.keychain_access_specified), requested_sandbox_kind: request .telemetry .as_ref() @@ -2441,10 +2454,10 @@ mod tests { "version": "1.1.0-alpha", "containment": "seatbelt", "seatbelt": { - "customIoKits": {"allow": ["AGXDeviceUserClient"]}, + "customIoKits": {"default": "deny", "allow": ["AGXDeviceUserClient"]}, "customMachLookups": {"default": "allow", "deny": ["com.example.*"]}, "customMachRegisters": {"local": {}}, - "customPosixShm": {"readWrite": ["/myapp.*"], "own": ["/myapp.tmp"]} + "customPosixShm": {"default": "allow", "readWrite": ["/myapp.*"], "own": ["/myapp.tmp"]} }, "process": {"commandLine": "echo dev"} }"#; @@ -2466,11 +2479,59 @@ mod tests { SeatbeltCustomDefault::Deny ); let shm = seatbelt.custom_posix_shm.unwrap(); - assert_eq!(shm.default, SeatbeltCustomDefault::Deny); + assert_eq!(shm.default, SeatbeltCustomDefault::Allow); assert!(shm.read.is_empty()); assert_eq!(shm.read_write, ["/myapp.*"]); assert_eq!(shm.own, ["/myapp.tmp"]); assert!(shm.deny.is_empty()); + + // Empty blocks are kept: they replace guiAccess's grant with nothing. + let json = r#"{ + "version": "1.1.0-alpha", + "seatbelt": {"customMachRegisters": {}, "customPosixShm": {}}, + "process": {"commandLine": "echo dev"} + }"#; + let MxcRequest::OneShot(request) = parse_exact_for_test(json).unwrap() else { + panic!("expected one-shot request"); + }; + let seatbelt = request.seatbelt.unwrap(); + let registers = seatbelt.custom_mach_registers.unwrap(); + assert!(registers.global.is_none() && registers.local.is_none()); + assert_eq!(seatbelt.custom_posix_shm.unwrap(), Default::default()); + assert!(seatbelt.custom_io_kits.is_none() && seatbelt.custom_mach_lookups.is_none()); + } + + #[test] + fn development_parser_records_which_lockdown_settings_were_written() { + let parse = |fields: &str| { + let json = format!( + r#"{{"version": "1.1.0-alpha", "containment": "seatbelt", {fields} + "process": {{"commandLine": "echo dev"}}}}"# + ); + match parse_exact_for_test(&json).unwrap() { + MxcRequest::OneShot(request) => request, + MxcRequest::StateAware(_) => panic!("expected one-shot request"), + } + }; + + let written = + parse(r#""ui": {"clipboard": "none"}, "seatbelt": {"keychainAccess": false},"#); + assert_eq!( + written.policy.ui_fields_specified, + UiFieldsSpecified { + disable: false, + clipboard: true, + injection: false, + } + ); + assert!(written.seatbelt.unwrap().keychain_access_specified); + + let omitted = parse(r#""seatbelt": {},"#); + assert_eq!( + omitted.policy.ui_fields_specified, + UiFieldsSpecified::default() + ); + assert!(!omitted.seatbelt.unwrap().keychain_access_specified); } #[test] diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index aac81b301b..16cf0ef0b0 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -168,6 +168,11 @@ pub struct SeatbeltConfig { #[serde(rename = "keychainAccess", default)] pub keychain_access: bool, + /// Whether the request supplied `keychainAccess`. captureDenials + /// `"allow"` mode keeps the Keychain deny only when it was supplied. + #[serde(skip)] + pub keychain_access_specified: bool, + /// Additional Mach service global-names to allow `mach-lookup` for. /// Escape hatch for callers that need to talk to a system service /// the baseline doesn't cover (e.g. opt-in agent integrations). @@ -220,6 +225,7 @@ impl Default for SeatbeltConfig { gui_access: false, nested_pty: true, keychain_access: false, + keychain_access_specified: false, extra_mach_lookups: Vec::new(), custom_io_kits: None, custom_mach_registers: None, @@ -698,6 +704,14 @@ impl Default for UiPolicy { } } +/// Which fields of the `ui` section were present in the request. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct UiFieldsSpecified { + pub disable: bool, + pub clipboard: bool, + pub injection: bool, +} + /// BaseProcessContainer-specific UI configuration (Windows only). /// Parsed from `processContainer.ui` in the JSON config. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -800,6 +814,11 @@ pub struct ContainerPolicy { /// was honored anywhere; it means only that the caller supplied one. #[serde(skip)] pub ui_specified: bool, + /// Which `ui` fields the caller supplied. Seatbelt's captureDenials + /// `"allow"` mode enforces only the UI denies the caller wrote. + /// Parse-derived, never on the wire. + #[serde(skip)] + pub ui_fields_specified: UiFieldsSpecified, /// BaseProcessContainer-specific UI config (Windows only, from processContainer.ui). pub base_process_ui: BaseProcessUiConfig, /// Windows denial capture (from `processContainer.captureDenials`). When diff --git a/src/mxc-sdk/tests/mxc_contract/v1_0_0/seatbelt.rs b/src/mxc-sdk/tests/mxc_contract/v1_0_0/seatbelt.rs index da4068743a..2a841cd7bc 100644 --- a/src/mxc-sdk/tests/mxc_contract/v1_0_0/seatbelt.rs +++ b/src/mxc-sdk/tests/mxc_contract/v1_0_0/seatbelt.rs @@ -204,3 +204,23 @@ fn rejects_seatbelt_capture_denials() { assert_invalid(json); } + +#[test] +fn rejects_seatbelt_custom_rules() { + for field in [ + "customIoKits", + "customMachLookups", + "customMachRegisters", + "customPosixShm", + ] { + let json = format!( + r#"{{ + "version": "1.0.0", + "seatbelt": {{"{field}": {{}}}}, + "process": {{"commandLine": "echo"}} + }}"# + ); + + assert_invalid(&json); + } +} diff --git a/tests/configs/seatbelt_capture_denials_gui_keychain.json b/tests/configs/seatbelt_capture_denials_gui_keychain.json new file mode 100644 index 0000000000..634c759d06 --- /dev/null +++ b/tests/configs/seatbelt_capture_denials_gui_keychain.json @@ -0,0 +1,22 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "containment": "seatbelt", + "process": { + "commandLine": "security list-keychains > /dev/null 2>&1 && echo KEYCHAIN_OK || echo KEYCHAIN_BLOCKED", + "timeout": 20000, + "env": [ + "HOME={{HOME}}" + ] + }, + "ui": { + "disable": false + }, + "seatbelt": { + "guiAccess": true, + "captureDenials": { + "mode": "allow", + "outputPath": "{{OUTPUT}}" + } + } +} diff --git a/tests/configs/seatbelt_gui_access_custom_all.json b/tests/configs/seatbelt_gui_access_custom_all.json new file mode 100644 index 0000000000..144f2900ef --- /dev/null +++ b/tests/configs/seatbelt_gui_access_custom_all.json @@ -0,0 +1,85 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "containment": "seatbelt", + "process": { + "commandLine": "/usr/bin/python3 {{TESTDIR}}/all_probe.py {{SHM}} {{UNLISTED_IOKIT}} "$TESTDIR/all_probe.py" <<'PROBE' +import _posixshmem as s, ctypes, os, sys + +prefix, unlisted_iokit = sys.argv[1:] +libc = ctypes.CDLL(None) + + +def mark(label, ok): + print(label + ("_OK" if ok else "_BLOCKED")) + + +bootstrap_port = ctypes.c_uint.in_dll(libc, "bootstrap_port").value +look_up = libc.bootstrap_look_up +look_up.argtypes = [ctypes.c_uint, ctypes.c_char_p, ctypes.POINTER(ctypes.c_uint)] +for label, name in [ + ("LOOKUP_ONE", "one"), ("LOOKUP_TWO", "two"), ("LOOKUP_PREFIX", "app.yes"), + ("LOOKUP_DENY", "app.no"), ("LOOKUP_DENY_PREFIX", "app.secret.x"), + ("LOOKUP_UNLISTED", "other"), +]: + port = ctypes.c_uint() + result = look_up(bootstrap_port, ("com.example.mxc.lookup." + name).encode(), ctypes.byref(port)) + if result not in (0, 1100, 1102): + print(label + "_UNEXPECTED_" + str(result)) + mark(label, result != 1100) + +# sandbox_check is variadic: the name must be passed as an extra argument. +check = libc.sandbox_check +check.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int] +for scope, kind in [("GLOBAL", 2), ("LOCAL", 3)]: + for label, name in [ + ("ONE", "one"), ("TWO", "two"), ("PREFIX", "app.yes"), ("DENY", "app.no"), + ("DENY_PREFIX", "app.secret.x"), ("UNLISTED", "other"), + ]: + full = "com.example.mxc." + scope.lower() + "." + name + mark("REGISTER_" + scope + "_" + label, + check(os.getpid(), b"mach-register", kind, ctypes.c_char_p(full.encode())) == 0) + +iokit = ctypes.CDLL("/System/Library/Frameworks/IOKit.framework/IOKit") +iokit.IOServiceMatching.restype = ctypes.c_void_p +iokit.IOServiceMatching.argtypes = [ctypes.c_char_p] +iokit.IOServiceGetMatchingService.restype = ctypes.c_uint +iokit.IOServiceGetMatchingService.argtypes = [ctypes.c_uint, ctypes.c_void_p] +iokit.IOServiceOpen.restype = ctypes.c_int +iokit.IOServiceOpen.argtypes = [ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint)] +iokit.IOServiceClose.argtypes = [ctypes.c_uint] +task = ctypes.c_uint.in_dll(libc, "mach_task_self_").value +for label, service in [ + ("IOKIT_EXACT", "IOSurfaceRoot"), ("IOKIT_PREFIX", "IOPMrootDomain"), + ("IOKIT_DENY", "AppleAPFSContainer"), ("IOKIT_UNLISTED", unlisted_iokit), +]: + connection = ctypes.c_uint() + service_port = iokit.IOServiceGetMatchingService(0, iokit.IOServiceMatching(service.encode())) + opened = service_port != 0 and iokit.IOServiceOpen(service_port, task, 0, ctypes.byref(connection)) == 0 + if opened: + iokit.IOServiceClose(connection.value) + mark(label, opened) + + +def shm(label, name, flags): + try: + os.close(s.shm_open(prefix + name, flags, 0o600)) + mark(label, True) + except OSError: + mark(label, False) + + +def unlink(label, name): + try: + s.shm_unlink(prefix + name) + mark(label, True) + except OSError: + mark(label, False) + + +create = os.O_RDWR | os.O_CREAT | os.O_EXCL +shm("SHM_READ_ONE", ".r1", os.O_RDONLY) +shm("SHM_READ_TWO", ".r2", os.O_RDONLY) +shm("SHM_READ_WRITE", ".r2", os.O_RDWR) +shm("SHM_RW_ONE", ".w1", os.O_RDWR) +shm("SHM_RW_PREFIX", ".wp.a", os.O_RDWR) +shm("SHM_RW_DENY", ".wp.no", os.O_RDWR) +shm("SHM_OWN_ONE", ".o1", create) +shm("SHM_OWN_PREFIX", ".op.a", create) +shm("SHM_OWN_DENY", ".op.no", create) +shm("SHM_UNLISTED", ".other", create) +unlink("SHM_UNLINK_ONE", ".o1") +unlink("SHM_UNLINK_PREFIX", ".op.a") +PROBE +chmod a+r "$TESTDIR/all_probe.py" + +# The unlisted IOKit check needs a service this host can open unsandboxed. +UNLISTED_IOKIT="" +for service in AppleSMC IODisplayWrangler AppleKeyStore; do + if /usr/bin/python3 - "$service" <<'OPEN'; then +import ctypes, sys +iokit = ctypes.CDLL("/System/Library/Frameworks/IOKit.framework/IOKit") +iokit.IOServiceMatching.restype = ctypes.c_void_p +iokit.IOServiceMatching.argtypes = [ctypes.c_char_p] +iokit.IOServiceGetMatchingService.restype = ctypes.c_uint +iokit.IOServiceGetMatchingService.argtypes = [ctypes.c_uint, ctypes.c_void_p] +iokit.IOServiceOpen.argtypes = [ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint)] +task = ctypes.c_uint.in_dll(ctypes.CDLL(None), "mach_task_self_").value +service = iokit.IOServiceGetMatchingService(0, iokit.IOServiceMatching(sys.argv[1].encode())) +connection = ctypes.c_uint() +sys.exit(0 if service and iokit.IOServiceOpen(service, task, 0, ctypes.byref(connection)) == 0 else 1) +OPEN + UNLISTED_IOKIT="$service" + break + fi +done +[ -n "$UNLISTED_IOKIT" ] || fail "no unlisted IOKit service opens on this host" + +shm_cleanup +/usr/bin/python3 -c ' +import _posixshmem as s, os, sys +for n in sys.argv[2:]: os.close(s.shm_open(sys.argv[1] + n, os.O_RDWR | os.O_CREAT, 0o600))' \ + "$SHM" .r1 .r2 .w1 .wp.a .wp.no || fail "could not create the host shared memory regions" +run_config "$(render seatbelt_gui_access_custom_all.json TESTDIR "$TESTDIR" SHM "$SHM" UNLISTED_IOKIT "$UNLISTED_IOKIT")" +grep -q "_UNEXPECTED_" <<<"$OUT" && fail "bootstrap_look_up returned an unexpected code" "$OUT" +for marker in \ + LOOKUP_ONE_OK LOOKUP_TWO_OK LOOKUP_PREFIX_OK \ + LOOKUP_DENY_BLOCKED LOOKUP_DENY_PREFIX_BLOCKED LOOKUP_UNLISTED_BLOCKED \ + REGISTER_GLOBAL_ONE_OK REGISTER_GLOBAL_TWO_OK REGISTER_GLOBAL_PREFIX_OK \ + REGISTER_GLOBAL_DENY_BLOCKED REGISTER_GLOBAL_DENY_PREFIX_BLOCKED REGISTER_GLOBAL_UNLISTED_BLOCKED \ + REGISTER_LOCAL_ONE_OK REGISTER_LOCAL_TWO_OK REGISTER_LOCAL_PREFIX_OK \ + REGISTER_LOCAL_DENY_BLOCKED REGISTER_LOCAL_DENY_PREFIX_BLOCKED REGISTER_LOCAL_UNLISTED_BLOCKED \ + IOKIT_EXACT_OK IOKIT_PREFIX_OK IOKIT_DENY_BLOCKED IOKIT_UNLISTED_BLOCKED \ + SHM_READ_ONE_OK SHM_READ_TWO_OK SHM_READ_WRITE_BLOCKED \ + SHM_RW_ONE_OK SHM_RW_PREFIX_OK SHM_RW_DENY_BLOCKED \ + SHM_OWN_ONE_OK SHM_OWN_PREFIX_OK SHM_OWN_DENY_BLOCKED SHM_UNLISTED_BLOCKED \ + SHM_UNLINK_ONE_OK SHM_UNLINK_PREFIX_OK; do + expect_ok "all custom blocks together: $marker" "$marker" +done + +# In captureDenials "allow" mode the same lists let unlisted names through and +# record them; deny entries still block, and granted names aren't recorded. +shm_cleanup +/usr/bin/python3 -c ' +import _posixshmem as s, os, sys +for n in sys.argv[2:]: os.close(s.shm_open(sys.argv[1] + n, os.O_RDWR | os.O_CREAT, 0o600))' \ + "$SHM" .r1 .r2 .w1 .wp.a .wp.no || fail "could not create the host shared memory regions" +CFG="$(render seatbelt_gui_access_custom_all.json TESTDIR "$TESTDIR" SHM "$SHM" UNLISTED_IOKIT "$UNLISTED_IOKIT")" +edit_config "$CFG" 'config["seatbelt"]["captureDenials"] = {"mode": "allow", "outputPath": "'"$TESTDIR"'/all.json"}' +run_config "$CFG" +for marker in \ + LOOKUP_ONE_OK LOOKUP_PREFIX_OK LOOKUP_DENY_BLOCKED LOOKUP_DENY_PREFIX_BLOCKED LOOKUP_UNLISTED_OK \ + REGISTER_GLOBAL_ONE_OK REGISTER_GLOBAL_DENY_BLOCKED REGISTER_GLOBAL_DENY_PREFIX_BLOCKED REGISTER_GLOBAL_UNLISTED_OK \ + REGISTER_LOCAL_ONE_OK REGISTER_LOCAL_DENY_BLOCKED REGISTER_LOCAL_DENY_PREFIX_BLOCKED REGISTER_LOCAL_UNLISTED_OK \ + IOKIT_EXACT_OK IOKIT_DENY_BLOCKED IOKIT_UNLISTED_OK \ + SHM_READ_WRITE_OK SHM_RW_DENY_BLOCKED SHM_OWN_DENY_BLOCKED SHM_UNLISTED_OK SHM_UNLINK_ONE_OK; do + expect_ok "allow mode with all custom blocks: $marker" "$marker" +done +REPORT="$(/usr/bin/python3 -c 'import json,sys +for line in sys.stdin: + if line.startswith("{\"type\":\"captureDenials\""): print(json.loads(line)["outputPath"]); break' <<<"$OUT")" +[ -f "$REPORT" ] || fail "the allow-mode run with all custom blocks writes a report" "$OUT" +/usr/bin/python3 - "$REPORT" "$SHM" <<'EOF' || fail "the allow-mode report lists unlisted and denied names only" "$(cat "$REPORT")" +import json, sys +path, shm = sys.argv[1:] +listed = {d["resource"] for d in json.load(open(path))["denials"]} +expected = { + "mach-lookup:com.example.mxc.lookup.other", + "mach-lookup:com.example.mxc.lookup.app.no", + "mach-register:com.example.mxc.global.other", + "mach-register:com.example.mxc.global.app.no", + "mach-register:com.example.mxc.local.other (per-pid)", + "mach-register:com.example.mxc.local.app.no (per-pid)", + "iokit-open-user-client:AppleAPFSUserClient", + "ipc-posix-shm-write-data:" + shm + ".r2", + "ipc-posix-shm-read-data:" + shm + ".wp.no", + "ipc-posix-shm-write-create:" + shm + ".op.no", + "ipc-posix-shm-write-create:" + shm + ".other", +} +granted = [r for r in listed if any(n in r for n in ( + ".one", ".two", ".app.yes", "IOSurfaceRootUserClient", "RootDomain", + shm + ".w1", shm + ".wp.a", shm + ".o1", shm + ".op.a"))] +missing = expected - listed +if missing or granted: + sys.exit(f"missing: {sorted(missing)}; granted but listed: {granted}") +EOF +pass "the allow-mode report lists unlisted and denied names, not granted ones" + # --- ordering: UI denies must survive the blanket allow ---------------------- run_config "$(render seatbelt_gui_access_clipboard_none.json)" From 832e0be7b5046fe797a311fb9a01777cc0a23887 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:43:15 -0800 Subject: [PATCH 3/7] rubber duck fixes --- docs/backends/seatbelt/seatbelt-backend.md | 4 +- .../seatbelt/common/profile_builder.rs | 71 +++++++++++++------ .../seatbelt/common/seatbelt_policy.rs | 69 +++++++++++++++--- .../run_seatbelt_capture_denials_test.sh | 13 ++++ 4 files changed, 125 insertions(+), 32 deletions(-) diff --git a/docs/backends/seatbelt/seatbelt-backend.md b/docs/backends/seatbelt/seatbelt-backend.md index 51f69e8f43..722f5026bc 100644 --- a/docs/backends/seatbelt/seatbelt-backend.md +++ b/docs/backends/seatbelt/seatbelt-backend.md @@ -604,7 +604,9 @@ MXC rejects a config when: `IOHIDLibUserClient` with `ui.injection: false`). Change that setting instead. This check also applies to `extraMachLookups`; - a `deny` entry would block something an explicit setting grants (for example - the pasteboard with `ui.clipboard` enabled). + the pasteboard with `ui.clipboard` enabled, `IOHIDLibUserClient` with + `ui.injection: true`, or a Keychain helper such as `com.apple.trustd` with + `keychainAccess: true`). To find the names an app needs, run it once with [`captureDenials`](#learning-mode-capturedenials) in `"allow"` mode. In that diff --git a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs index 02adf9c617..a5f47b0830 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs @@ -200,6 +200,7 @@ pub(crate) fn build_profile_with_terminals( // Setting denies follow every allow so no grant reopens them. write_ui_policy_denies(&mut out, request, capture.as_ref()); write_keychain_denies(&mut out, request, capture.as_ref()); + write_nested_pty_denies(&mut out, request); write_custom_denies(&mut out, request); // Policy-derived deny rules go LAST so they win on conflict. @@ -705,8 +706,7 @@ fn write_ui_policy_denies( /// Emit rules so the inner process can call `posix_openpt()` and allocate /// its own pty. fn write_nested_pty_rules(out: &mut String, request: &ExecutionRequest) { - let enabled = request.seatbelt.as_ref().is_none_or(|c| c.nested_pty); - if !enabled { + if !nested_pty_enabled(request) { return; } out.push_str(";; --- nestedPty: allow inner process to allocate its own pty ---\n"); @@ -716,6 +716,21 @@ fn write_nested_pty_rules(out: &mut String, request: &ExecutionRequest) { out.push_str(" (literal \"/dev/ptmx\"))\n"); } +fn nested_pty_enabled(request: &ExecutionRequest) -> bool { + request.seatbelt.as_ref().is_none_or(|c| c.nested_pty) +} + +/// `nestedPty` defaults to true, so `false` is always written in the config +/// and stays denied even under captureDenials allow mode. +fn write_nested_pty_denies(out: &mut String, request: &ExecutionRequest) { + if nested_pty_enabled(request) { + return; + } + out.push_str(";; --- nestedPty=false: deny pty allocation ---\n"); + out.push_str("(deny pseudo-tty)\n"); + out.push_str("(deny file-read* file-write* file-ioctl (literal \"/dev/ptmx\"))\n"); +} + /// Emit rules so `Security.framework` / `keytar` can reach `securityd` /// and read/write the user's Keychain. Off by default — opt in via /// `seatbelt.keychainAccess: true`. @@ -755,23 +770,18 @@ fn write_keychain_rules( })?; out.push_str(";; --- keychainAccess: Mach IPC for Keychain (securityd, prefs, XPC, LS) ---\n"); - out.push_str("(allow mach-lookup\n"); + out.push_str("(allow mach-lookup"); for name in seatbelt_policy::KEYCHAIN_GRANTED_NAMES { - let _ = writeln!(out, " (global-name {})", quote_scheme(name)); + let _ = write!(out, "\n (global-name {})", quote_scheme(name)); + } + for prefix in seatbelt_policy::KEYCHAIN_GRANTED_PREFIXES { + let _ = write!( + out, + "\n (global-name-regex #\"^{}\")", + prefix.replace('.', "\\.") + ); } - // trustd handles SecTrustSettingsCopyTrustSettings; without it Security - // logs "failed to copy trust settings of system certificate-N" for every - // cert in the system root store on every TLS handshake. - out.push_str(" (global-name \"com.apple.trustd\")\n"); - out.push_str(" (global-name \"com.apple.trustd.agent\")\n"); - out.push_str(" (global-name \"com.apple.ocspd\")\n"); - out.push_str(" (global-name \"com.apple.cfprefsd.daemon\")\n"); - out.push_str(" (global-name \"com.apple.cfprefsd.agent\")\n"); - out.push_str(" (global-name \"com.apple.xpcd\")\n"); - // Seatbelt has no glob in (global-name); use regex for the lsd.* family - // (lsd.modifydb, lsd.mapdb, lsd.openurl, …). Anchored to - // `com.apple.lsd.` so we don't accidentally match unrelated services. - out.push_str(" (global-name-regex #\"^com\\.apple\\.lsd\\.\"))\n"); + out.push_str(")\n"); out.push_str(";; --- keychainAccess: MDS keychain metadata + trustd protected store ---\n"); out.push_str("(allow file-read*\n"); @@ -1946,17 +1956,32 @@ mod tests { } #[test] - fn nested_pty_false_omits_pty_rules() { + fn nested_pty_false_denies_pty_allocation_in_every_mode() { let mut r = req(); r.seatbelt = Some(SeatbeltConfig { nested_pty: false, ..Default::default() }); let p = build_profile(&r).unwrap(); - assert!(!p.contains("nestedPty")); - assert!(!p.contains("/dev/ptmx")); - // pseudo-tty allow should also not be present. - assert!(!p.contains("(allow pseudo-tty)")); + assert!(!p.contains("(allow pseudo-tty)"), "{p}"); + assert!(p.contains("(deny pseudo-tty)"), "{p}"); + + // Allow mode's (allow default) must not reopen a written nestedPty=false. + let mut r = capture_request(CaptureDenialsMode::Allow, false); + r.seatbelt.as_mut().unwrap().nested_pty = false; + let p = capture_profile(&r); + assert!(!p.contains("(allow pseudo-tty)"), "{p}"); + assert!( + p.contains("(deny pseudo-tty (with message \"mxc-capture-test\"))\n"), + "{p}" + ); + assert!( + p.contains( + "(deny file-read* file-write* file-ioctl (with message \"mxc-capture-test\") \ + (literal \"/dev/ptmx\"))\n" + ), + "{p}" + ); } #[test] @@ -1976,7 +2001,7 @@ mod tests { let p = build_profile(&r).unwrap(); assert!(p.contains("(allow iokit-open)")); assert_eq!(p.contains("(allow pseudo-tty)"), nested_pty, "{p}"); - assert_eq!(p.contains("/dev/ptmx"), nested_pty, "{p}"); + assert_eq!(p.contains("(deny pseudo-tty)"), !nested_pty, "{p}"); } } diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs index 88cab7978c..f54d343315 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs @@ -53,8 +53,21 @@ pub const RUN_MARKER_NAME_PREFIX: &str = "com.microsoft.mxc.run."; /// data-protection keychain. pub const KEYCHAIN_SERVER_NAMES: &[&str] = &["com.apple.SecurityServer"]; pub const KEYCHAIN_SERVER_PREFIXES: &[&str] = &["com.apple.securityd"]; -/// Keychain servers that `keychainAccess: true` grants by name. -pub const KEYCHAIN_GRANTED_NAMES: &[&str] = &["com.apple.SecurityServer", "com.apple.securityd"]; +/// Mach services that `keychainAccess: true` grants by name. `trustd` copies +/// system trust settings; without it every TLS handshake logs a failure per +/// root certificate. +pub const KEYCHAIN_GRANTED_NAMES: &[&str] = &[ + "com.apple.SecurityServer", + "com.apple.securityd", + "com.apple.trustd", + "com.apple.trustd.agent", + "com.apple.ocspd", + "com.apple.cfprefsd.daemon", + "com.apple.cfprefsd.agent", + "com.apple.xpcd", +]; +/// Mach service prefixes that `keychainAccess: true` grants (LaunchServices). +pub const KEYCHAIN_GRANTED_PREFIXES: &[&str] = &["com.apple.lsd."]; /// Mach services controlled by `ui.disable`. pub const UI_SERVICE_NAMES: &[&str] = &[ "com.apple.windowserver.active", @@ -172,6 +185,11 @@ fn setting_names( KEYCHAIN_GRANTED_NAMES .iter() .map(|n| NamePattern::Exact(n)) + .chain( + KEYCHAIN_GRANTED_PREFIXES + .iter() + .map(|p| NamePattern::Prefix(p)), + ) .collect(), ) } else { @@ -217,12 +235,19 @@ fn setting_names( setting: "ui.disable", }); } - CustomNameKind::IoKit if !ui.injection => denied.push(SettingNames { - patterns: vec![NamePattern::Exact(HID_USER_CLIENT_CLASS)], - description: "the HID input client", - setting: "ui.injection", - }), - CustomNameKind::IoKit | CustomNameKind::MachRegister | CustomNameKind::PosixShm => {} + CustomNameKind::IoKit => { + let target = if ui.injection { + &mut granted + } else { + &mut denied + }; + target.push(SettingNames { + patterns: vec![NamePattern::Exact(HID_USER_CLIENT_CLASS)], + description: "the HID input client", + setting: "ui.injection", + }); + } + CustomNameKind::MachRegister | CustomNameKind::PosixShm => {} } (denied, granted) } @@ -883,6 +908,34 @@ mod tests { request.policy.ui.clipboard = ClipboardPolicy::All; let msg = validate_seatbelt_custom_rules(&request).unwrap_err(); assert!(msg.contains("ui.clipboard"), "{msg}"); + + // Every name keychainAccess grants, not only the Keychain servers. + for entry in [ + "com.apple.trustd", + "com.apple.xpcd", + "com.apple.cfprefsd.*", + "com.apple.lsd.mapdb", + "com.apple.lsd.*", + ] { + let msg = custom_error(SeatbeltConfig { + keychain_access: true, + ..lookups(&[], &[entry]) + }); + assert!(msg.contains("customMachLookups.deny"), "{entry}: {msg}"); + assert!(msg.contains("keychainAccess"), "{entry}: {msg}"); + } + + let mut request = seatbelt_request(SeatbeltConfig { + custom_io_kits: Some(SeatbeltCustomRules { + deny: vec!["IOHID*".into()], + ..Default::default() + }), + ..Default::default() + }); + request.policy.ui.injection = true; + let msg = validate_seatbelt_custom_rules(&request).unwrap_err(); + assert!(msg.contains("customIoKits.deny"), "{msg}"); + assert!(msg.contains("ui.injection"), "{msg}"); } #[test] diff --git a/tests/scripts/run_seatbelt_capture_denials_test.sh b/tests/scripts/run_seatbelt_capture_denials_test.sh index c1165f190b..f2198df313 100755 --- a/tests/scripts/run_seatbelt_capture_denials_test.sh +++ b/tests/scripts/run_seatbelt_capture_denials_test.sh @@ -566,6 +566,19 @@ EOF pass "allow mode with keychainAccess $keychain reports the Keychain" done +# nestedPty defaults to true, so false is always written and stays enforced. +run_config "$(render seatbelt_opt_nested_pty_off.json)" --audit +expect_ok "allow mode keeps pty allocation blocked with nestedPty=false" "PTY_BLOCKED" +read_pointer "allow mode with nestedPty=false writes a report" +/usr/bin/python3 - "$REPORT" <<'EOF' || fail "allow mode with nestedPty=false does not report the pty" "$(cat "$REPORT")" +import json, sys +doc = json.load(open(sys.argv[1])) +assert any("pseudo-tty" in d["resource"] or d["resource"] == "/dev/ptmx" + for d in doc["denials"]) +EOF +pass "allow mode with nestedPty=false reports the pty" +rm -f "$REPORT" "${REPORT%.json}.verbose.json" + # --- a real GUI app: which Mach services and IOKit drivers it needs ---------- # A minimal AppKit app (JavaScript for Automation) that opens a window. Only From bf0108a8d84491b945af34094aa9b14eb4aa7092 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:05:56 -0800 Subject: [PATCH 4/7] fix override check w/ custommachregisters --- docs/development/architecture/versioning.md | 7 +++---- .../src/backends/seatbelt/common/seatbelt_policy.rs | 11 ++++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md index 6d8a5004fd..ff797a82f9 100644 --- a/docs/development/architecture/versioning.md +++ b/docs/development/architecture/versioning.md @@ -367,10 +367,9 @@ In every contract version, explicit Seatbelt settings now take precedence over - `readonlyPaths` and `deniedPaths` entries inside `/private/tmp` and `/private/var/folders` apply. -The Seatbelt backend also rejects, with `policy_validation`, an -`extraMachLookups` entry that names only a service an explicit setting denies, -such as `com.apple.SecurityServer` without `keychainAccess: true`. Change that -setting instead. +The Seatbelt backend also rejects an `extraMachLookups` entry that names only +a service an explicit setting denies, such as `com.apple.SecurityServer` +without `keychainAccess: true`. Change that setting instead. This exception applies within V1 and does not require a new versioned API surface. diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs index f54d343315..6ef07fa73a 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs @@ -334,7 +334,9 @@ pub fn validate_seatbelt_custom_rules(request: &ExecutionRequest) -> Result<(), return Ok(()); }; let sets = custom_rule_sets(config); - let has_custom = !sets.is_empty() || config.custom_posix_shm.is_some(); + let has_custom = !sets.is_empty() + || config.custom_mach_registers.is_some() + || config.custom_posix_shm.is_some(); if has_custom && config.profile_override.is_some() { return Err( "Seatbelt: seatbelt.profileOverride replaces the generated profile, so \ @@ -819,6 +821,13 @@ mod tests { }); assert!(msg.contains("profileOverride"), "{msg}"); + let msg = custom_error(SeatbeltConfig { + profile_override: Some("(version 1)".into()), + custom_mach_registers: Some(SeatbeltCustomMachRegisters::default()), + ..Default::default() + }); + assert!(msg.contains("profileOverride"), "{msg}"); + let msg = custom_error(SeatbeltConfig { extra_mach_lookups: vec!["com.example.a".into()], ..lookups(&[], &[]) From 9c70c079a872a6e1036e0c5ab27588a3b27c6084 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:58:12 -0800 Subject: [PATCH 5/7] clarify omitted is still denial that blocks custom machs for keychain, clipboard, pty, etc. --- docs/backends/seatbelt/seatbelt-backend.md | 7 ++++--- docs/development/architecture/versioning.md | 23 ++++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/backends/seatbelt/seatbelt-backend.md b/docs/backends/seatbelt/seatbelt-backend.md index 722f5026bc..41948be22b 100644 --- a/docs/backends/seatbelt/seatbelt-backend.md +++ b/docs/backends/seatbelt/seatbelt-backend.md @@ -379,9 +379,10 @@ It also grants read **and** write across all of `/private/tmp` and `/private/var/folders`. Your `filesystem` policy is applied after this grant, so `readonlyPaths` and `deniedPaths` entries inside those directories still apply. -Explicit settings always win over `guiAccess`: `nestedPty` alone controls PTY -creation, `keychainAccess` controls the Keychain, and the `ui` settings control -WindowServer, the pasteboard, and HID input. +Settings always win over `guiAccess`, whether written or left at their default: +`nestedPty` alone controls PTY creation, `keychainAccess` controls the +Keychain, and the `ui` settings control WindowServer, the pasteboard, and HID +input. diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md index ff797a82f9..c55ec3312f 100644 --- a/docs/development/architecture/versioning.md +++ b/docs/development/architecture/versioning.md @@ -359,17 +359,26 @@ versioned API surface. ### Servicing exception: Seatbelt settings take precedence over `guiAccess` -In every contract version, explicit Seatbelt settings now take precedence over -`seatbelt.guiAccess`: - -- `nestedPty: false` blocks PTY creation. -- `keychainAccess: false` blocks the Keychain servers. +In every contract version, Seatbelt settings now take precedence over +`seatbelt.guiAccess`, whether they are written in the config or omitted and +left at a denying default. Previously `guiAccess` silently overrode them; this +change is intentional. + +- `nestedPty: false` blocks PTY creation. `nestedPty` defaults to `true`. +- `keychainAccess: false` or an omitted `keychainAccess` blocks the Keychain + servers. +- `ui.clipboard: "none"` or an omitted `ui.clipboard` blocks every + `com.apple.pasteboard.` server. - `readonlyPaths` and `deniedPaths` entries inside `/private/tmp` and `/private/var/folders` apply. +In `captureDenials` `"allow"` mode, omitted settings allow and report access +instead, so the report shows what they would block. + The Seatbelt backend also rejects an `extraMachLookups` entry that names only -a service an explicit setting denies, such as `com.apple.SecurityServer` -without `keychainAccess: true`. Change that setting instead. +a service a setting denies, written or by default, such as +`com.apple.SecurityServer` without `keychainAccess: true`. Change that setting +instead. This exception applies within V1 and does not require a new versioned API surface. From 160191e478448b166dd01aa063df08ec6e67f441 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:09:47 -0800 Subject: [PATCH 6/7] shared memory gets reported --- docs/backends/seatbelt/seatbelt-backend.md | 7 +-- .../seatbelt/common/capture_analysis.rs | 35 ++++++++++++- .../src/core/mxc_common/policy_identity.rs | 50 +++++++++++++++++++ .../seatbelt_capture_denials_shm_grant.json | 25 ++++++++++ .../run_seatbelt_capture_denials_test.sh | 22 +++++++- 5 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 tests/configs/seatbelt_capture_denials_shm_grant.json diff --git a/docs/backends/seatbelt/seatbelt-backend.md b/docs/backends/seatbelt/seatbelt-backend.md index 41948be22b..3227bb3775 100644 --- a/docs/backends/seatbelt/seatbelt-backend.md +++ b/docs/backends/seatbelt/seatbelt-backend.md @@ -442,9 +442,10 @@ with these macOS differences: - Verbose signatures use `providerGuid` `com.apple.security.sandbox`, event ID `0`, and the Seatbelt operation (for example `file-read-data`) as - `eventName`. Any `target` that contains `/` becomes ``, other - values are kept, and values over 256 characters are cut short. There is no - account-name replacement or digest. + `eventName`. Any `target` that contains `/` becomes ``, except + shared memory names, which are kept so you can use them in + `customPosixShm`. Other values are kept, and values over 256 characters are + cut short. There is no account-name replacement or digest. `deniedResourcesTruncated: true` means some denials may be missing. Delete the report, its `.verbose.json` file, and any `.seatbelt.log` when you're done. diff --git a/src/mxc-sdk/src/backends/seatbelt/common/capture_analysis.rs b/src/mxc-sdk/src/backends/seatbelt/common/capture_analysis.rs index 1fff7c610f..e62a2de646 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/capture_analysis.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/capture_analysis.rs @@ -234,7 +234,8 @@ fn report_signature( reason: CaptureVerboseLoggingOutcomeReason, classified: Option<(ResourceType, AccessType)>, ) -> CaptureVerboseLoggingSignature { - let target = if report.target.contains('/') { + // Shared memory names start with `/` but are not file paths. + let target = if report.target.contains('/') && !report.operation.starts_with("ipc-posix-shm") { REDACTED_PATH.to_string() } else { bounded_property(report.target) @@ -591,6 +592,38 @@ mod tests { })); } + #[test] + fn verbose_report_keeps_shared_memory_names() { + let mut shm = event("Sandbox: app(7) allow ipc-posix-shm-read-data /myapp.region"); + shm.message.push_str(GRANT_TAG_SUFFIX); + let analysis = analyze_events( + &[ + shm, + event("Sandbox: cat(7) deny(1) file-read-data /Users/me/secret.txt"), + ], + false, + ); + let target = |op: &str| { + analysis + .network_verbose_logging + .signatures + .iter() + .find(|g| g.signature.event_name.as_deref() == Some(op)) + .and_then(|g| { + g.signature + .properties + .iter() + .find(|(key, _)| key == "target") + }) + .map(|(_, value)| value.clone()) + }; + assert_eq!( + target("ipc-posix-shm-read-data").as_deref(), + Some("/myapp.region") + ); + assert_eq!(target("file-read-data").as_deref(), Some(REDACTED_PATH)); + } + #[test] fn analysis_bounds_unique_denials() { let events: Vec<_> = (0..=MAX_UNIQUE_DENIALS) diff --git a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs index 310f515e9b..e7e6334471 100644 --- a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs +++ b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs @@ -47,6 +47,10 @@ //! WSLC and Windows Sandbox configuration is hashed at the `wslc` and //! `windowsSandbox` root keys. //! +//! In Seatbelt `captureDenials` `"allow"` mode, the hash also covers whether +//! `keychainAccess` and each `ui` field were written, because an omitted +//! setting reports access instead of denying it. +//! //! `ContainerPolicy::network_proxy` is `#[serde(skip)]`, so the proxy's //! credential-bearing URL cannot reach the hash through the blanket policy //! serialization even by accident; the enforcement-relevant parts (enabled, @@ -328,6 +332,19 @@ fn policy_projection(request: &ExecutionRequest) -> Value { if let Some(Value::Object(cd)) = seatbelt_map.get_mut("captureDenials") { cd.remove("output_path"); } + // In allow mode, an omitted setting reports instead of denying. + if seatbelt_capture_allows(seatbelt) { + let ui = policy.ui_fields_specified; + seatbelt_map.insert( + "settingsWritten".into(), + serde_json::json!({ + "keychainAccess": seatbelt.as_ref().is_some_and(|c| c.keychain_access_specified), + "uiDisable": ui.disable, + "uiClipboard": ui.clipboard, + "uiInjection": ui.injection, + }), + ); + } } root.insert("seatbelt".into(), seatbelt_value); root.insert( @@ -352,6 +369,13 @@ fn policy_projection(request: &ExecutionRequest) -> Value { Value::Object(root) } +fn seatbelt_capture_allows(seatbelt: &Option) -> bool { + seatbelt + .as_ref() + .and_then(|c| c.capture_denials.as_ref()) + .is_some_and(|cd| cd.mode == crate::mxc_common::models::CaptureDenialsMode::Allow) +} + /// The enforcement-relevant, non-credential parts of the proxy configuration: /// whether a proxy is in force, its host, and its port. The original URL is /// deliberately dropped because it can carry `user:password@` userinfo. @@ -777,6 +801,32 @@ mod tests { assert_ne!(base, with_path(None, CaptureDenialsMode::Allow)); } + #[test] + fn seatbelt_allow_mode_hashes_whether_settings_were_written() { + use crate::mxc_common::models::{CaptureDenialsConfig, CaptureDenialsMode, SeatbeltConfig}; + + let hash = |mode, keychain: bool, clipboard: bool| { + let mut r = request(); + r.policy.ui_fields_specified.clipboard = clipboard; + r.seatbelt = Some(SeatbeltConfig { + keychain_access_specified: keychain, + capture_denials: Some(CaptureDenialsConfig { + mode, + ..Default::default() + }), + ..Default::default() + }); + policy_hash(&r) + }; + let omitted = hash(CaptureDenialsMode::Allow, false, false); + assert_ne!(omitted, hash(CaptureDenialsMode::Allow, true, false)); + assert_ne!(omitted, hash(CaptureDenialsMode::Allow, false, true)); + assert_eq!( + hash(CaptureDenialsMode::Block, false, false), + hash(CaptureDenialsMode::Block, true, true) + ); + } + #[test] fn telemetry_settings_do_not_change_the_hash() { let baseline = policy_hash(&request()); diff --git a/tests/configs/seatbelt_capture_denials_shm_grant.json b/tests/configs/seatbelt_capture_denials_shm_grant.json new file mode 100644 index 0000000000..92277f0086 --- /dev/null +++ b/tests/configs/seatbelt_capture_denials_shm_grant.json @@ -0,0 +1,25 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "containment": "seatbelt", + "process": { + "commandLine": "/usr/bin/python3 {{TESTDIR}}/app/shm.py {{SHM}} "$TESTDIR/app/shm.py" <<'EOF' +import sys +from multiprocessing import shared_memory +owner = shared_memory.SharedMemory(name=sys.argv[1], create=True, size=16) +owner.buf[0] = 7 +reader = shared_memory.SharedMemory(name=sys.argv[1]) +print("SHM_OK" if reader.buf[0] == 7 else "SHM_FAILED") +reader.close() +owner.close() +owner.unlink() +EOF +chmod a+r "$TESTDIR/app/shm.py" +run_config "$(render seatbelt_capture_denials_shm_grant.json TESTDIR "$TESTDIR" SHM "$SHM" OUTPUT "$TESTDIR/out/shm.json")" +expect_ok "the shared memory workload ran" "SHM_OK" +read_pointer "the shared memory run writes a report" +expect_granted "the verbose report names the shared memory it granted" ipc-posix-shm-write-create "/$SHM" + # --- rejections -------------------------------------------------------------- expect_rejected "captureDenials cannot be combined with profileOverride" \ From 4504421081dfba71dcdde7d84bc911a9ba896a78 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:28:30 -0800 Subject: [PATCH 7/7] default containment now set for sdk test --- src/mxc-sdk/src/core/mxc_common/config_parser.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index ff9782ad57..8f3eeea554 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -2488,6 +2488,7 @@ mod tests { // Empty blocks are kept: they replace guiAccess's grant with nothing. let json = r#"{ "version": "1.1.0-alpha", + "containment": "seatbelt", "seatbelt": {"customMachRegisters": {}, "customPosixShm": {}}, "process": {"commandLine": "echo dev"} }"#;