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 diff --git a/src/config/profile.rs b/src/config/profile.rs index c9f0972..fb55fd5 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 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)"); +}