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"}
}"#;