Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions docs/PROFILES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand All @@ -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
23 changes: 19 additions & 4 deletions src/config/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,11 @@ pub fn load_profiles(names: &[String], custom_dir: Option<&Path>) -> Vec<Profile
}
}

// Try global profile directory
if let Some(config_dir) = dirs::config_dir() {
let path = config_dir
.join("sx/profiles")
// Try global profile directory (~/.config/sx/profiles/)
// Uses ~/.config to match global config path (global.rs)
if let Some(home) = dirs::home_dir() {
let path = home
.join(".config/sx/profiles")
.join(format!("{}.toml", name));
if path.exists() {
match load_profile(&path) {
Expand Down Expand Up @@ -297,6 +298,20 @@ 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);
match &mut existing.raw {
Some(current) => {
current.push('\n');
current.push_str(raw);
}
None => existing.raw = Some(raw.clone()),
}
}
}
}

result
Expand Down
56 changes: 55 additions & 1 deletion tests/profile_test.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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)");
}