From 8eabb4c1d129b127d815de9050e0fd7935b0bbe5 Mon Sep 17 00:00:00 2001 From: Pierre Tomasina Date: Thu, 26 Feb 2026 15:43:38 +0800 Subject: [PATCH 1/4] fix(profile): resolve merge conflict and improve profile system - Fix profile loader path: use ~/.config/sx/profiles/ instead of dirs::config_dir() (which resolves to ~/Library/Application Support on macOS, breaking profile loading) - Add seatbelt raw rule merging: compose_profiles() now concatenates raw seatbelt rules from all profiles instead of silently dropping them - Merge resolution: kept both ExecSugid union-merge and seatbelt rule concatenation logic in compose_profiles() --- src/config/profile.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/config/profile.rs b/src/config/profile.rs index c9f0972..b2e149b 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -207,10 +207,11 @@ pub fn load_profiles(names: &[String], custom_dir: Option<&Path>) -> Vec Profile { } } } + + // Seatbelt: concatenate raw rules from all profiles + if let Some(seatbelt) = &profile.seatbelt { + if let Some(raw) = &seatbelt.raw { + let existing = result + .seatbelt + .get_or_insert_with(ProfileSeatbelt::default); + match &mut existing.raw { + Some(current) => { + current.push('\n'); + current.push_str(raw); + } + None => existing.raw = Some(raw.clone()), + } + } + } } result From 22035006f461cba7fba58c0ba412dd49d58f6676 Mon Sep 17 00:00:00 2001 From: Pierre Tomasina Date: Thu, 26 Feb 2026 15:54:25 +0800 Subject: [PATCH 2/4] test(profile): add seatbelt raw rule composition tests --- tests/profile_test.rs | 56 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/tests/profile_test.rs b/tests/profile_test.rs index ad78d65..4f1fc7c 100644 --- a/tests/profile_test.rs +++ b/tests/profile_test.rs @@ -1,4 +1,6 @@ -use sx::config::profile::{compose_profiles, load_profile, load_profiles, BuiltinProfile, Profile}; +use sx::config::profile::{ + compose_profiles, load_profile, load_profiles, BuiltinProfile, Profile, ProfileSeatbelt, +}; use sx::config::schema::{ExecSugid, NetworkMode}; use tempfile::TempDir; @@ -327,3 +329,55 @@ fn test_compose_profiles_exec_sugid_bool_overrides_paths() { let composed = compose_profiles(&[p1, p2]); assert_eq!(composed.allow_exec_sugid, Some(ExecSugid::Allow(true))); } + +// === Seatbelt Profile Compose Tests === + +#[test] +fn test_compose_profiles_seatbelt_single() { + let p = Profile { + seatbelt: Some(ProfileSeatbelt { + raw: Some("(allow iokit-get-properties)".into()), + }), + ..Default::default() + }; + let composed = compose_profiles(&[p]); + let raw = composed.seatbelt.unwrap().raw.unwrap(); + assert_eq!(raw, "(allow iokit-get-properties)"); +} + +#[test] +fn test_compose_profiles_seatbelt_concatenation() { + let p1 = Profile { + seatbelt: Some(ProfileSeatbelt { + raw: Some("(allow iokit-get-properties)".into()), + }), + ..Default::default() + }; + let p2 = Profile { + seatbelt: Some(ProfileSeatbelt { + raw: Some("(allow file-issue-extension)".into()), + }), + ..Default::default() + }; + let composed = compose_profiles(&[p1, p2]); + let raw = composed.seatbelt.unwrap().raw.unwrap(); + assert!(raw.contains("(allow iokit-get-properties)")); + assert!(raw.contains("(allow file-issue-extension)")); +} + +#[test] +fn test_compose_profiles_seatbelt_none_skipped() { + let p1 = Profile { + seatbelt: Some(ProfileSeatbelt { + raw: Some("(allow iokit-get-properties)".into()), + }), + ..Default::default() + }; + let p2 = Profile { + seatbelt: None, + ..Default::default() + }; + let composed = compose_profiles(&[p1, p2]); + let raw = composed.seatbelt.unwrap().raw.unwrap(); + assert_eq!(raw, "(allow iokit-get-properties)"); +} From 1c01ba71c6f02e325059fbc5e3f2d288d244d4e2 Mon Sep 17 00:00:00 2001 From: Pierre Tomasina Date: Thu, 26 Feb 2026 15:56:08 +0800 Subject: [PATCH 3/4] style(profile): format seatbelt merging code --- src/config/profile.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/config/profile.rs b/src/config/profile.rs index b2e149b..fb55fd5 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -302,9 +302,7 @@ pub fn compose_profiles(profiles: &[Profile]) -> Profile { // Seatbelt: concatenate raw rules from all profiles if let Some(seatbelt) = &profile.seatbelt { if let Some(raw) = &seatbelt.raw { - let existing = result - .seatbelt - .get_or_insert_with(ProfileSeatbelt::default); + let existing = result.seatbelt.get_or_insert_with(ProfileSeatbelt::default); match &mut existing.raw { Some(current) => { current.push('\n'); From b709793096d1618a7fa968060c341636dd9245b7 Mon Sep 17 00:00:00 2001 From: Pierre Tomasina Date: Thu, 26 Feb 2026 15:58:30 +0800 Subject: [PATCH 4/4] docs: document custom profile seatbelt rules and resolution order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add [seatbelt] raw rules section to PROFILES.md and CONFIGURATION.md - Document profile resolution order (builtin → project dir → ~/.config/sx/profiles/) - Add seatbelt concatenation to merging rules - Update README to reference custom profile capabilities --- README.md | 2 +- docs/CONFIGURATION.md | 16 ++++++++++++++++ docs/PROFILES.md | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fcc27f4..31778d6 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ allow_write = ["/tmp/build"] pass_env = ["NODE_ENV", "DEBUG"] ``` -Custom profiles go in `~/.config/sx/profiles/name.toml`. +Custom profiles go in `~/.config/sx/profiles/name.toml`. They support filesystem paths, env vars, exec sugid, and raw seatbelt rules for advanced sandbox operations. See [docs/PROFILES.md](docs/PROFILES.md). ## Usage diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ba0ddc9..322f862 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -80,6 +80,22 @@ pass_env = ["MYPROJECT_TOKEN"] Use with `sx myproject -- command`. +Custom profiles also support raw seatbelt rules for advanced sandbox operations (IOKit, Mach services, app bundles): + +```toml +# ~/.config/sx/profiles/playwright.toml +network_mode = "online" + +[seatbelt] +raw = """ +(allow iokit-open-user-client + (iokit-user-client-class "RootDomainUserClient")) +(allow iokit-get-properties) +""" +``` + +Raw rules from all active profiles are concatenated in order. See [PROFILES.md](PROFILES.md) for details. + ## Setuid/Setgid Execution Some binaries like `/bin/ps` are setuid/setgid. Seatbelt blocks these by default with `forbidden-exec-sugid`. Use `allow_exec_sugid` to opt in. diff --git a/docs/PROFILES.md b/docs/PROFILES.md index bfeda1d..a14170a 100644 --- a/docs/PROFILES.md +++ b/docs/PROFILES.md @@ -116,6 +116,40 @@ Use it: sx mycompany -- ./run.sh ``` +### Raw Seatbelt Rules + +For advanced use cases (IOKit, Mach services, app bundles), custom profiles support raw seatbelt rules: + +```toml +# ~/.config/sx/profiles/playwright.toml +network_mode = "online" + +[seatbelt] +raw = """ +(allow iokit-open-user-client + (iokit-user-client-class "RootDomainUserClient") + (iokit-user-client-class "AGXDeviceUserClient") + (iokit-user-client-class "IOSurfaceRootUserClient")) +(allow iokit-get-properties) +(allow file-issue-extension) +""" + +[filesystem] +allow_read = ["~/Library/Caches/ms-playwright/"] +allow_write = ["~/Library/Caches/ms-playwright/"] +``` + +Raw rules are appended verbatim to the generated seatbelt profile. Use `sx --dry-run myprofile` to verify the output. + +### Profile Resolution Order + +When you specify a profile name, `sx` searches in this order: + +1. **Built-in profiles** (embedded in the binary) +2. **Project custom directory** (if configured) +3. **`~/.config/sx/profiles/{name}.toml`** +4. **Fallback** to `online` with a warning if not found + ## Project Profiles In `.sandbox.toml`: @@ -131,3 +165,4 @@ profiles = ["rust", "localhost"] 2. **Filesystem paths:** union (no duplicates) 3. **Env vars:** union of pass/deny lists 4. **Exec sugid:** path lists are unioned; mixing paths and booleans → last wins +5. **Seatbelt raw rules:** concatenated from all profiles in order