Skip to content

[Seatbelt] Add custom Mach, IOKit, and shared memory lists - #1720

Open
Elliot (theelliotm) wants to merge 8 commits into
mainfrom
user/emichlin/mach-iokit-customizable
Open

Elliot (theelliotm) wants to merge 8 commits into
mainfrom
user/emichlin/mach-iokit-customizable

Conversation

@theelliotm

@theelliotm Elliot (theelliotm) commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

📖 Description

Adds 1.1.0-alpha Seatbelt settings that let a GUI app be granted only the Mach services, IOKit classes, and POSIX shared memory it actually needs.

1. Custom lists (feature)

New seatbelt fields in the 1.1.0-alpha contract:

Field Controls Shape
customMachLookups Mach services the app may look up default (deny/allow), allow, deny
customMachRegisters Mach services the app may register global and local blocks, each with the same shape
customIoKits IOKit user-client classes the app may open default, allow, deny
customPosixShm POSIX shared memory names default (deny/read/readWrite/create/own), read, readWrite, create, own, deny
  • An entry ending in * matches a prefix. deny entries win over everything else.
  • customPosixShm uses access levels: read, readWrite, create (read, write, and create), and own (adds delete). Each listed name gets exactly its list's level, even under a broader default, and when entries overlap the longer one wins.
  • With ui.injection: true, the IOHIDLibUserClient grant stays when customIoKits replaces guiAccess's IOKit grant.
  • Each block that is present replaces the matching guiAccess grant. The blocks also work without guiAccess.
  • extraMachLookups is now documented as deprecated in favor of customMachLookups.
  • The schema and Node wire types are regenerated. Older contracts (1.0.0, 0.9.0-alpha) reject these fields.

2. Explicit settings win over guiAccess (bug fix)

Before this change, turning on guiAccess quietly undid some settings written in the config. Now, in every contract version:

  • nestedPty: false blocks PTY creation.
  • keychainAccess off (the default) blocks the Keychain servers, even though guiAccess allows all Mach lookups.
  • ui.clipboard "none" (the default) blocks every com.apple.pasteboard.* service, not only com.apple.pasteboard.1.
  • readonlyPaths and deniedPaths entries inside /private/tmp and /private/var/folders stay enforced.

versioning.md records the nestedPty, Keychain, clipboard, and path changes as a V1 servicing exception.

3. Validation

The config is rejected before the sandbox is created when:

  • a custom block is combined with profileOverride, or customMachLookups with extraMachLookups;
  • an entry is malformed: a bare * (the error names the list-specific fix), a * that isn't at the end, an IOKit entry that isn't a class name, or a shared memory name over 31 bytes;
  • a customMachLookups entry uses MXC's reserved com.microsoft.mxc.run. prefix;
  • a shared memory name is in two access lists;
  • an allow entry names only something a setting denies, whether written or omitted (for example com.apple.SecurityServer without keychainAccess);
  • a deny entry would block something a setting grants (for example the pasteboard with ui.clipboard enabled, IOHIDLibUserClient with ui.injection: true, or com.apple.trustd with keychainAccess: true).

4. Learning mode (captureDenials)

  • In "allow" mode, every custom list acts as default: "allow", so the app runs and the report lists each name to add. deny entries and customPosixShm list levels still apply.
  • Settings that are omitted from the config will allow and report. Written deniedPaths, ingress.hostLoopback: "deny", nestedPty, keychainAccess, and ui settings stay enforced; a written network default: "deny" does not.
  • Accesses that only guiAccess's broad grants allow are reported in the verbose report, so you can see which names to move into the custom lists.

🔗 References

Test It Yourself Here:

I created a simple demo to show Microsoft Edge functioning inside MXC with just its required Mach/IOKit/POSIX grants. The grants were found using learning mode.

Download this: seatbelt_msft_edge 2.zip

Run  ./run_sandboxed_edge.sh <path-to-mxc-exec-mac>

You can either quit MXC or ctrl + click Edge on the dock and press Quit to end the demo.

🔍 Validation

Run on macOS (Apple silicon) at the head of this branch.

Command (from src/ unless noted) Result
cargo fmt --all -- --check ✅ passed
cargo clippy -p mxc-sdk -p mxc_darwin -p mxc_ffi --all-targets -- -D warnings ✅ passed
cargo clippy -p mxc-sdk --lib --tests --target x86_64-unknown-linux-gnu -- -D warnings ✅ passed
cargo clippy -p mxc-sdk --lib --tests --target x86_64-pc-windows-msvc -- -D warnings ✅ passed
cargo test -p mxc-sdk --lib ✅ 1968 passed
cargo test -p mxc-sdk --test mxc_contract_v1_1_0_alpha ✅ 382 passed
cargo test -p mxc-sdk --test mxc_contract_v1_0_0 ✅ 282 passed
cargo test -p mxc-sdk --test mxc_contract_v0_9_0_alpha ✅ 278 passed
cargo test -p mxc-sdk --test mxc_contract_version_boundaries ✅ 21 passed
cargo test -p mxc-sdk --test streaming ✅ 33 passed
node scripts/versioning/validate-configs.js (repo root) ✅ passed
npx tsc --noEmit -p . (sdk/node/) ✅ passed
./build-mac.sh (repo root) ✅ passed
bash tests/scripts/run_seatbelt_gui_access_test.sh ✅ 117 checks passed
bash tests/scripts/run_seatbelt_capture_denials_test.sh ✅ 138 checks passed
bash tests/scripts/run_seatbelt_rejections_test.sh ✅ 16 checks passed
bash tests/scripts/run_seatbelt_options_test.sh ✅ 20 checks passed
bash tests/scripts/run_seatbelt_tty_test.sh ✅ 1 check passed
bash tests/scripts/run_seatbelt_ui_test.sh ✅ 5 checks passed
bash tests/scripts/run_seatbelt_examples_test.sh ✅ 28 checks passed

New end-to-end coverage, wired into the nightly Seatbelt run through run_seatbelt_all_tests.sh:

  • each custom block on its own, and all four together with several entries each, in block and allow mode;
  • each guiAccess override fix (nestedPty, keychainAccess, clipboard, /private/tmp read-only paths);
  • learning-mode reports for omitted and written clipboard, Keychain, and nestedPty settings, and allow-mode enforcement of written ui.disable and ui.injection;
  • customPosixShm read, write, create, delete, and new-region creation checked separately for every level, overlapping entries, and allow mode;
  • rejection of malformed and conflicting custom configs through the executor, asserting the workload never starts.

The nestedPty: false allow-mode regression was confirmed to fail before its fix (PTY_OK) and pass after it (PTY_BLOCKED).

Not run: Windows and Linux runtime suites (this change is Seatbelt-only; both targets were checked with Clippy) and the .NET SDK tests (no .NET changes).

✅ Checklist

  • Signed the Contributor License Agreement
  • Linked to an issue
  • For features or changes requiring a spec: linked the issue with maintainer-applied feature-accepted
  • For implementation: linked the required approved and merged design/API spec, or explained why no spec is required
  • Feature issue remains open unless this PR completes all agreed implementation, validation, and documentation
  • Updated documentation (if applicable)
  • Updated Copilot instructions (if build, architecture, or conventions changed)
  • If this PR changes Cargo.lock, the dependency-feed-check check passes (see pull request builds)

📋 Issue Type

  • Bug fix
  • Feature
  • Task
Microsoft Reviewers: Open in CodeFlow

Copilot AI balanced review requested due to automatic review settings November 10, 2026 02:45
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Previously valid V1 requests now fail validation without explicit maintainer acceptance of the compatibility break, and custom-policy validation inconsistencies remain unresolved.

3 open issues
What changed in this PR

Adds fine-grained Mach, IOKit, and POSIX shared memory controls to MXC’s macOS Seatbelt backend, building on the learning mode introduced in #1668.

Changes:

  • Adds four custom-list settings to the 1.1.0-alpha contract, with validation, schema, and generated Node types.
  • Makes explicit restrictions take precedence over broad guiAccess grants and distinguishes omitted settings in learning mode.
  • Expands contract and macOS functional coverage, with documentation of usage and V1 servicing changes.
File Description
tests/​scripts/​run_seatbelt_tty_test.sh Updates PTY expectations under guiAccess.
tests/​scripts/​run_seatbelt_options_test.sh Checks explicit Keychain deny rules.
tests/​scripts/​run_seatbelt_gui_access_test.sh Exercises custom lists and policy precedence.
tests/​scripts/​run_seatbelt_capture_denials_test.sh Extends learning-mode and reporting scenarios.
tests/​configs/​seatbelt_gui_access_readonly_tmp.json Adds read-only temporary-path regression fixture.
tests/​configs/​seatbelt_gui_access_nested_pty_off.json Adds GUI PTY-denial fixture.
tests/​configs/​seatbelt_gui_access_keychain_off.json Adds GUI Keychain-denial fixture.
tests/​configs/​seatbelt_gui_access_custom_registers.json Configures registration scopes and IOKit rules.
tests/​configs/​seatbelt_gui_access_custom_posix_shm.json Configures shared memory access levels.
tests/​configs/​seatbelt_gui_access_custom_lookups_deny.json Adds Mach lookup deny-list fixture.
tests/​configs/​seatbelt_gui_access_custom_lookups_allow.json Adds Mach lookup allow-list fixture.
tests/​configs/​seatbelt_gui_access_custom_clipboard_none.json Tests clipboard denial over custom grants.
tests/​configs/​seatbelt_gui_access_custom_all.json Combines all four custom-list settings.
tests/​configs/​seatbelt_capture_denials_gui_keychain.json Tests omitted Keychain settings in learning mode.
tests/​configs/​seatbelt_capture_denials_gui_app_custom_allow.json Runs a GUI app with empty learning-mode lists.
src/​mxc-sdk/​tests/​mxc_contract/​v1_1_0_alpha/​seatbelt.rs Tests custom-list contract shapes.
src/​mxc-sdk/​tests/​mxc_contract/​v1_0_0/​seatbelt.rs Verifies V1.0 rejects new fields.
src/​mxc-sdk/​src/​core/​mxc_contract/​dev/​stable.rs Defines development contract fields and types.
src/​mxc-sdk/​src/​core/​mxc_contract/​dev/​mod.rs Exports new contract types.
src/​mxc-sdk/​src/​core/​mxc_common/​wire.rs Adds intermediate custom-list representations.
src/​mxc-sdk/​src/​core/​mxc_common/​models.rs Adds runtime rules and setting-presence tracking.
src/​mxc-sdk/​src/​core/​mxc_common/​config_parser.rs Maps custom lists and preserves setting presence.
src/​mxc-sdk/​src/​core/​mxc_common/​config_contract_adapters/​v1_0/​one_shot.rs Leaves new fields absent for V1.0.
src/​mxc-sdk/​src/​core/​mxc_common/​config_contract_adapters/​v0_9/​one_shot.rs Leaves new fields absent for V0.9.
src/​mxc-sdk/​src/​core/​mxc_common/​config_contract_adapters/​dev/​one_shot.rs Converts development custom-list settings.
src/​mxc-sdk/​src/​backends/​seatbelt/​common/​seatbelt_runner.rs Invokes validation and updates PTY assertions.
src/​mxc-sdk/​src/​backends/​seatbelt/​common/​seatbelt_policy.rs Validates names, combinations, and setting conflicts.
src/​mxc-sdk/​src/​backends/​seatbelt/​common/​profile_builder.rs Generates custom rules and enforces precedence.
src/​mxc-sdk/​src/​backends/​seatbelt/​common/​capture_analysis.rs Reuses the pasteboard prefix for classification.
sdk/​node/​src/​generated/​v1_1_0_alpha/​wire.ts Adds generated TypeScript custom-list types.
schemas/​dev/​mxc-config.schema.1.1.0-alpha.json Adds generated custom-list schema definitions.
docs/​schema.md Documents new fields and learning-mode behavior.
docs/​development/​architecture/​versioning.md Records development-only fields and servicing exceptions.
docs/​development/​architecture/​designs/​1591-seatbelt-learning-mode/​README.md Clarifies omitted versus written settings.
docs/​containment-policy/​dev/​policy.md Summarizes development Seatbelt options.
docs/​backends/​seatbelt/​seatbelt-backend.md Explains custom lists, precedence, and learning-mode usage.

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings November 10, 2026 22:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Policy hashes conflate settings with different access decisions, and report redaction hides leading-slash shared-memory names needed to configure custom grants.

1 open issue
2 previously missed issues

In code that hasn't changed since last review

Medium severity Preserve shared-memory names when redacting report targets

src/​mxc-sdk/​src/​backends/​seatbelt/​common/​profile_builder.rs:586

[P2] Shared-memory accesses allowed by these new :grant rules appear only in the verbose report, but capture_analysis::report_signature redacts any target containing /. A name such as /myapp.region is therefore unavailable in either JSON report, preventing callers from discovering names for customPosixShm; the custom default: "allow" grant has the same issue. Preserve shared-memory names when redacting filesystem paths, and add a regression test for a grant with a leading-slash name.

Medium severity Include effective settings in policy hash projection

src/​mxc-sdk/​src/​core/​mxc_common/​models.rs:821

[P2] In captureDenials allow mode, omitting ui.clipboard versus writing "none" produces the same mxc.PolicyHash, although one permits access and the other blocks it; omitted versus explicit keychainAccess: false has the same problem. policy_identity::policy_projection serializes these structs, so both new presence fields are excluded by #[serde(skip)]. Include the effective setting decisions explicitly in the hash projection, keep the fields out of transport serialization, and add hash regression tests for these pairs.

2 resolved since last review

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings November 10, 2026 23:09
@theelliotm
Elliot (theelliotm) marked this pull request as ready for review November 10, 2026 23:10
@theelliotm
Elliot (theelliotm) requested a review from a team November 10, 2026 23:10
@theelliotm
Elliot (theelliotm) requested a review from a team as a code owner November 10, 2026 23:10
@theelliotm Elliot (theelliotm) self-assigned this Nov 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Maintainer review is needed because the new Mach, IOKit, and shared-memory controls lack a linked approved design, and their native enforcement was not independently validated on supported macOS hosts.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings November 10, 2026 23:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Maintainer review is needed to confirm the new macOS sandbox controls’ security semantics and grant/deny precedence, as no approved design is linked and runtime enforcement could not be reproduced in this environment.

No open issues 🎉

Review effort: Balanced

@richiemsft

Copy link
Copy Markdown
Contributor

Review findings

Versioning: Please keep parity with existing behavior for V1 and below, including 1.0.0 and 0.9.0-alpha. Start enforcing the stricter permissions in 1.1+ (1.1.0-alpha onward): deny by default and require callers to explicitly enable the access they need.

Medium findings

# Severity File Lines Vulnerability Confidence
1 🟡 MEDIUM profile_builder.rs 579–582 With GUI and injection enabled, a custom IOKit list removes the blanket grant without restoring IOHIDLibUserClient, silently disabling injection. Emit the enabled HID-specific grant independently of the custom list and add a behavioral regression. —
2 🟡 MEDIUM seatbelt_policy.rs 263–275 In learning mode, omitting keychainAccess permits and reports access, but adding com.apple.SecurityServer to customMachLookups.allow rejects the request. Align validation with enforcement's capture-mode and presence rules; keep explicit restrictions enforced. —
3 🟡 MEDIUM seatbelt_runner.rs 176–177 Custom-policy rejection is tested through helpers, not the backend entry point. Removing this validation call could leave the tests green. Add end-to-end rejection cases for malformed/conflicting policies and assert that the workload never executes. —
4 🟡 MEDIUM config_parser.rs 1734–1737 Parsed explicit ui.disable and ui.injection restrictions lack learning-mode regression coverage; manually setting presence flags in profile tests does not protect parsing. Test omitted fields against explicit disable: true and injection: false through parsing and enforcement. —
5 🟡 MEDIUM run_seatbelt_gui_access_test.sh 263–266 Shared-memory denial probes use O_RDWR and do not test unlink. Read denial can hide an unintended write grant. Precreate regions and independently assert read-only, write-only and unlink denial, including overlapping grants and learning mode. —
6 🟡 MEDIUM config_parser.rs 1332–1338 Shared normalization converts omitted custom-list defaults into explicit Deny, losing presence and placing backend policy defaulting in the parser. Preserve the optional value through normalization and resolve the default in Seatbelt's effective-policy construction. —
7 🟡 MEDIUM seatbelt-backend.md 423 The learning-mode promise that written settings remain enforced is too broad: an explicit network egress default of deny withholds an allow, so learning mode's allow default permits traffic. Enumerate preserved restrictions and explicitly document exceptions for network and custom-list denying defaults. —
8 🟡 MEDIUM seatbelt-backend.md; dev/stable.rs 579; 414 Describing custom default-deny as granting only the allowlist is inaccurate: baseline grants and enabled-setting grants remain. Clarify that each list replaces its corresponding broad GUI grant, not every grant in that category. —
9 🟡 MEDIUM seatbelt_policy.rs 297–300 The wildcard error recommends default: "allow" even for shared-memory read or deny entries. That can add write/create permissions or reverse the intended denial. Make the remedy list-specific and recommend concrete names or supported prefixes instead. —

Review scope: source inspection at 45044210; macOS runtime enforcement was not independently reproduced. Items 3–5 are regression-coverage gaps, and item 6 is an architectural/presence issue, not a separately demonstrated runtime failure. Numerical confidence scores for the Medium findings were not retained in the consolidated report.

- Keep the ui.injection HID grant when customIoKits replaces guiAccess's IOKit grant.
- Keep omitted custom-list defaults unset in the parser; Seatbelt resolves them.
- Add leveled customPosixShm defaults (deny/read/readWrite/create/own), a create
  list, and exact per-entry levels; reject a name in two level lists.
- Give list-specific remedies for bare "*" entries.
- Add rejection, learning-mode UI, and shared memory level e2e coverage.
- Clarify learning-mode and custom default-deny docs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings November 11, 2026 21:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Maintainer confirmation is needed because no approved design is linked for the new containment controls, and their native macOS enforcement precedence could not be independently validated.

No open issues 🎉

Review effort: Balanced

@theelliotm

Copy link
Copy Markdown
Contributor Author

Richie Gomez (@richiemsft) thanks for the review. Addressed in bc59687:

Versioning: Not keeping V1 parity: most of the old behavior was buggy (e.g. guiAccess overriding keychainAccess), so the fixes apply to all versions.

  1. Fixed: with ui.injection: true, the IOHIDLibUserClient grant is now written even when customIoKits replaces guiAccess's IOKit grant (unit test, since CI hosts have no guaranteed HID device).
  2. Intended: an omitted setting counts as deny, so a custom entry that contradicts it is rejected in every mode, and the docs now say so.
  3. Added e2e rejection cases through the executor that assert the workload never starts: bare *, profileOverride, extraMachLookups, reserved name, setting conflicts both ways, a shm name in two lists, and a name over 31 bytes.
  4. Added a parser test for omitted vs explicit ui.disable/ui.injection, plus learning-mode e2e checks for ui.disable (true/omitted) and ui.injection (false/omitted).
  5. Shm probes now pre-create regions and check read, write, create, delete, and new-region creation separately for every level, overlapping entries, and learning mode.
  6. Fixed: omitted custom-list defaults stay unset through parsing and Seatbelt resolves them to deny.
  7. The learning-mode table now lists exactly what "allow" mode still enforces and names the network and custom-list default exceptions.
  8. Reworded: each list replaces only guiAccess's matching broad grant; baseline and other settings' grants remain.
  9. Bare * now gets a list-specific remedy (e.g. set customPosixShm.default to that list's level) instead of always suggesting default: "allow".

Also changed: customPosixShm levels

  • Levels are read, readWrite, create (read/write/create), and own (adds delete); default accepts deny or any level, and create is a new list for apps that create regions but shouldn't delete them.
  • Each listed name gets exactly its list's level, even under a broader default: MXC adds a deny for the operations above that level.
  • When entries overlap, the longer one wins (e.g. own: ["/app.*"] with read: ["/app.config"] makes /app.config read-only).
  • The same entry in two level lists is rejected, and deny wins over every level.
  • List levels and deny entries still apply in captureDenials "allow" mode.

@richiemsft

Richie Gomez (richiemsft) commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Two follow-up points on bc59687f:

  1. Shared-memory write-denial coverage : The level probe now tests unlink independently, but the write check still uses O_RDWR. A read denial can make that fail even if write access is accidentally allowed. Please add an O_WRONLY probe against a precreated denied region, with a successful write-only control, in both normal and learning modes.

  2. nestedPty must be explicit in 1.1+: Normalization still defaults omitted nestedPty to true, including for 1.1.0-alpha. For 1.1+ (1.1.0-alpha onward), default it to false and require explicit nestedPty: true to grant nested PTY allocation. Keep the existing default for V1 and below. Please update the documentation and add version-boundary tests for omitted, explicit false, and explicit true.

@theelliotm

Copy link
Copy Markdown
Contributor Author

Regarding point 1, macOS rejects 'O_WRONLY'. Not possible to test that.
Regarding point 2, could that be in a separate PR? Open a separate issue and tag me on it.

@richiemsft

Copy link
Copy Markdown
Contributor

Elliot (@theelliotm) Thanks for the clarification. You're right about O_WRONLY: Apple's macOS shm_open documentation requires exactly one of O_RDONLY or O_RDWR. The write-only probe recommendation was incorrect; withdrawing point 1 as an outstanding test request.

Tracked the nestedPty default change separately in #1735 and tagged you there. That follow-up requests explicit opt-in starting with 1.1.0-alpha, while preserving the defaults for V1 and below; it can be handled in a separate PR.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants