Skip to content

Commit eede442

Browse files
authored
feat: add allow_exec_sugid config option for setuid binary execution (#26)
* feat: add allow_exec_sugid config option (#22) Add opt-in execution of setuid/setgid binaries for MCP server support. Resolves forbidden-exec-sugid violations while maintaining secure deny-by-default. - New ExecSugid enum: Allow(bool) or Paths(Vec<String>) - Config field with TOML syntax: false (deny), true (allow all), ["/bin/ps"] - Profile composing with last-wins/paths-union merge semantics - Config merge: project overrides global, paths are unioned - CLI flag --allow-exec-sugid PATH (repeatable, path-based only) - Seatbelt rule generation with path validation - explain command shows sugid status; init template includes example - 27 new tests across schema, seatbelt, config, profile, and CLI * fix(explain): show deny for empty exec_sugid paths list * docs: add allow_exec_sugid configuration and usage guide - Add allow_exec_sugid examples to global and project config templates - Document three modes: deny (default), allow all, allow specific paths - Add CLI flag usage and merge semantics explanation - Update PROFILES.md with custom profile example and merge rules - Add SECURITY.md section explaining deny-by-default behavior and seatbelt rules - Update generated seatbelt profile example with sugid rule example * style: format code with cargo fmt
1 parent c23b0f1 commit eede442

15 files changed

Lines changed: 615 additions & 99 deletions

CLAUDE.md

Lines changed: 0 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,5 @@
1-
# CLAUDE.md
2-
3-
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4-
5-
## Project Overview
6-
71
`sx` (sandbox-shell) is a Rust CLI that wraps shell sessions and commands in macOS Seatbelt sandboxes. It protects developers from malicious code in npm packages, untrusted repositories, and build scripts by restricting filesystem and network access.
82

9-
## Development Commands
10-
11-
```bash
12-
# Build
13-
cargo build # Debug build
14-
cargo build --release # Release build (with LTO)
15-
16-
# Test
17-
cargo test # Run all tests
18-
cargo test test_name # Run specific test
19-
cargo test --test cli_test # Run specific integration test file
20-
21-
# Run from source
22-
cargo run -- --help
23-
cargo run -- echo "test"
24-
cargo run -- --dry-run online node # Preview seatbelt profile
25-
```
26-
27-
## Architecture
28-
29-
The codebase follows a layered architecture with clear separation of concerns:
30-
31-
```
32-
src/
33-
├── main.rs # Binary entry point: calls sx::run()
34-
├── lib.rs # Library entry point: parses args, dispatches to commands
35-
├── cli/
36-
│ ├── args.rs # Clap argument definitions
37-
│ └── commands.rs # Command implementations (init, explain, dry_run, execute)
38-
├── config/
39-
│ ├── schema.rs # Core types: Config, NetworkMode, FilesystemConfig
40-
│ ├── profile.rs # Profile system: builtin profiles (base, rust, claude, etc.)
41-
│ ├── global.rs # Global config loading (~/.config/sx/config.toml)
42-
│ ├── project.rs # Project config loading (.sandbox.toml)
43-
│ └── merge.rs # Config/profile merging logic
44-
├── sandbox/
45-
│ ├── seatbelt.rs # Seatbelt profile generation (the core sandbox logic)
46-
│ ├── executor.rs # sandbox-exec invocation
47-
│ ├── trace.rs # Real-time violation streaming via macOS `log stream`
48-
│ └── violations.rs # Violation parsing and log file handling
49-
├── detection/
50-
│ └── project_type.rs # Auto-detect project type from marker files
51-
├── shell/
52-
│ ├── integration.rs # Shell prompt/completion support
53-
│ └── prompt.rs # Prompt indicator formatting with ANSI colors
54-
└── utils/
55-
└── paths.rs # Path expansion (~, environment variables)
56-
```
57-
58-
### Key Data Flow
59-
60-
1. **CLI args** (`cli/args.rs`) → parsed by Clap
61-
2. **Config loading** (`config/`) → global + project configs merged
62-
3. **Profile composition** (`config/profile.rs`) → profiles stacked (base + rust + online, etc.)
63-
4. **Seatbelt generation** (`sandbox/seatbelt.rs`) → generates macOS sandbox-exec profile
64-
5. **Execution** (`sandbox/executor.rs`) → spawns `sandbox-exec -f profile.sb command`
65-
66-
### Security Model
67-
68-
The sandbox uses a **deny-by-default** approach:
69-
- **Reads**: Denied by default, only explicit `allow_read` paths are accessible (system paths like `/usr`, `/bin`, `/Library`, `/System`)
70-
- **Writes**: Denied by default, allowed only for working directory + explicit `allow_write` paths
71-
- **Network**: Configurable (offline/localhost/online)
72-
733
**Critical Seatbelt Rules**:
744
1. Root literal `(allow file-read* (literal "/"))` is required for path traversal - processes need to read `/` to resolve paths
755
2. Seatbelt uses last-match-wins semantics when rules have matching filter types - deny rules after allow rules take precedence for nested paths (e.g., allow `/home` then deny `/home/.ssh`)
@@ -78,14 +8,6 @@ The sandbox uses a **deny-by-default** approach:
788
**Configuration Options**:
799
- `inherit_base = false` in `.sandbox.toml` skips the base profile for full custom control over allowed paths
8010

81-
### Profile System
82-
83-
Profiles are composable configurations that stack on top of each other. Order matters - later profiles override network mode but merge filesystem paths.
84-
85-
Built-in profiles: `base`, `online`, `localhost`, `rust`, `claude`, `gpg`
86-
87-
Custom profiles can be added to `~/.config/sx/profiles/name.toml`.
88-
8911
## Programming Rules
9012

9113
Produce code following **LEAN, CLEAN, MINIMAL but thoughtful KISS & YAGNI principle**.

docs/CONFIGURATION.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ default_profiles = ["base"] # always include these
1313
shell = "/bin/zsh" # shell inside sandbox
1414
prompt_indicator = true # show [sx:mode] in prompt
1515
inherit_base = true # include base profile
16+
# allow_exec_sugid = ["/bin/ps"] # allow specific setuid/setgid binaries
1617

1718
[filesystem]
1819
allow_read = [
@@ -49,6 +50,7 @@ profiles = ["rust"] # profiles for this project
4950
network = "localhost" # override network mode
5051
inherit_global = true # inherit from global config
5152
inherit_base = true # include base profile (false for full custom)
53+
# allow_exec_sugid = ["/bin/ps"] # allow specific setuid/setgid binaries
5254

5355
[filesystem]
5456
allow_read = ["./vendor"]
@@ -78,6 +80,32 @@ pass_env = ["MYPROJECT_TOKEN"]
7880

7981
Use with `sx myproject -- command`.
8082

83+
## Setuid/Setgid Execution
84+
85+
Some binaries like `/bin/ps` are setuid/setgid. Seatbelt blocks these by default with `forbidden-exec-sugid`. Use `allow_exec_sugid` to opt in.
86+
87+
Three modes:
88+
89+
| Value | Effect |
90+
|-------|--------|
91+
| `false` (default) | Deny all setuid/setgid execution |
92+
| `true` | Allow all setuid/setgid execution |
93+
| `["/bin/ps"]` | Allow only listed binaries |
94+
95+
```toml
96+
# .sandbox.toml
97+
[sandbox]
98+
allow_exec_sugid = ["/bin/ps", "/usr/bin/newgrp"]
99+
```
100+
101+
CLI flag (repeatable, path-based only):
102+
103+
```bash
104+
sx --allow-exec-sugid /bin/ps --allow-exec-sugid /usr/bin/newgrp -- ps aux
105+
```
106+
107+
**Merge semantics:** when both global and project configs specify path lists, paths are unioned. Otherwise project overrides global.
108+
81109
## Precedence
82110

83111
1. CLI flags (highest)

docs/PROFILES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ Create in `~/.config/sx/profiles/`:
100100
```toml
101101
# ~/.config/sx/profiles/mycompany.toml
102102
network_mode = "online"
103+
allow_exec_sugid = ["/bin/ps"] # allow specific setuid/setgid binaries
103104

104105
[filesystem]
105106
allow_read = ["/opt/mycompany"]
@@ -129,3 +130,4 @@ profiles = ["rust", "localhost"]
129130
1. **Network mode:** last profile with a mode wins
130131
2. **Filesystem paths:** union (no duplicates)
131132
3. **Env vars:** union of pass/deny lists
133+
4. **Exec sugid:** path lists are unioned; mixing paths and booleans → last wins

docs/SECURITY.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,20 @@ Everything else (`~/.config/gh`, `~/.netrc`, `~/.gnupg`…) is blocked by deny-b
5252

5353
Even with `online`, your credentials can't be read. Can't exfiltrate what you can't see.
5454

55+
### Setuid/Setgid Execution
56+
57+
Setuid/setgid binaries (e.g., `/bin/ps`, `/usr/bin/newgrp`) are denied by default. Seatbelt raises `forbidden-exec-sugid` when a sandboxed process tries to execute one.
58+
59+
Opt in selectively via `allow_exec_sugid`:
60+
61+
| Mode | Seatbelt rule |
62+
|------|---------------|
63+
| Deny (default) | No rule — blocked by `(deny default)` |
64+
| Allow all | `(allow process-exec* (with no-sandbox))` |
65+
| Specific paths | `(allow process-exec (with no-sandbox) (literal "/bin/ps"))` |
66+
67+
Paths are validated against injection attacks (no quotes, newlines, or null bytes).
68+
5569
### Environment Sanitization
5670

5771
Blocked by default:
@@ -71,6 +85,10 @@ Blocked by default:
7185
(allow process-exec)
7286
(allow signal (target self))
7387
88+
; Setuid/setgid execution denied (default)
89+
; Or, if allow_exec_sugid = ["/bin/ps"]:
90+
; (allow process-exec (with no-sandbox) (literal "/bin/ps"))
91+
7492
; Required for path resolution
7593
(allow file-read* (literal "/"))
7694
(allow file-read-metadata) ; Required for DNS resolution

src/cli/args.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ pub struct Args {
8383
#[arg(long = "deny-read", value_name = "PATH")]
8484
pub deny_read: Vec<String>,
8585

86+
/// Allow execution of setuid/setgid binary at PATH (e.g., /bin/ps)
87+
#[arg(long = "allow-exec-sugid", value_name = "PATH")]
88+
pub allow_exec_sugid: Vec<String>,
89+
8690
/// Profiles to apply (e.g., online, rust, claude)
8791
#[arg(value_name = "PROFILES")]
8892
pub profiles: Vec<String>,

src/cli/commands.rs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ use std::path::{Path, PathBuf};
99
use super::args::Args;
1010
use crate::config::project::{load_project_config, PROJECT_CONFIG_NAME};
1111
use crate::config::{
12-
compose_profiles, load_global_config, load_profiles, merge_configs, Config, NetworkMode,
13-
Profile,
12+
compose_profiles, load_global_config, load_profiles, merge_configs, Config, ExecSugid,
13+
NetworkMode, Profile,
1414
};
1515
use crate::detection::project_type::detect_project_types;
1616
use crate::sandbox::executor::execute_sandboxed_with_trace;
@@ -44,6 +44,19 @@ pub fn explain(args: &Args) -> Result<()> {
4444
println!("Network Mode: {:?}", context.params.network_mode);
4545
println!();
4646

47+
// Exec sugid
48+
match &context.params.allow_exec_sugid {
49+
ExecSugid::Allow(true) => println!("Setuid/Setgid Execution: ALLOW ALL"),
50+
ExecSugid::Paths(paths) if !paths.is_empty() => {
51+
println!("Setuid/Setgid Execution: specific binaries");
52+
for path in paths {
53+
println!(" + {}", path);
54+
}
55+
}
56+
_ => println!("Setuid/Setgid Execution: denied (default)"),
57+
}
58+
println!();
59+
4760
// Working directory
4861
println!("Working Directory (full access):");
4962
println!(" {}", context.params.working_dir.display());
@@ -275,6 +288,9 @@ fn build_sandbox_params(
275288
// Determine network mode (CLI > profile > config)
276289
let network_mode = determine_network_mode(args, profile, config);
277290

291+
// Determine exec sugid policy (CLI > profile > config)
292+
let allow_exec_sugid = determine_exec_sugid(args, profile, config);
293+
278294
// Collect paths with expansions
279295
let mut allow_read = collect_allow_read_paths(config, profile, &args.allow_read);
280296
let mut deny_read = collect_deny_read_paths(config, profile, &args.deny_read);
@@ -324,6 +340,7 @@ fn build_sandbox_params(
324340
allow_write: allow_write.into_iter().map(PathBuf::from).collect(),
325341
allow_list_dirs: allow_list_dirs.into_iter().map(PathBuf::from).collect(),
326342
raw_rules,
343+
allow_exec_sugid,
327344
}
328345
}
329346

@@ -346,6 +363,22 @@ fn determine_network_mode(args: &Args, profile: &Profile, config: &Config) -> Ne
346363
.unwrap_or(config.sandbox.default_network)
347364
}
348365

366+
/// Determine exec sugid policy with precedence: CLI > profile > config
367+
fn determine_exec_sugid(args: &Args, profile: &Profile, config: &Config) -> ExecSugid {
368+
// CLI paths take highest precedence
369+
if !args.allow_exec_sugid.is_empty() {
370+
return ExecSugid::Paths(args.allow_exec_sugid.clone());
371+
}
372+
373+
// Profile setting
374+
if let Some(sugid) = &profile.allow_exec_sugid {
375+
return sugid.clone();
376+
}
377+
378+
// Config setting
379+
config.sandbox.allow_exec_sugid.clone()
380+
}
381+
349382
/// Collect allow-read paths from config, profile, and CLI
350383
fn collect_allow_read_paths(config: &Config, profile: &Profile, cli: &[String]) -> Vec<String> {
351384
let mut paths = Vec::new();
@@ -398,6 +431,10 @@ profiles = []
398431
# Default network mode: "offline", "online", or "localhost"
399432
# network = "offline"
400433
434+
# Allow execution of setuid/setgid binaries (default: false)
435+
# false = deny all, true = allow all, or list specific paths
436+
# allow_exec_sugid = ["/bin/ps"]
437+
401438
[filesystem]
402439
# Additional paths this project needs to read
403440
allow_read = []

src/config/merge.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use super::schema::{Config, FilesystemConfig, SandboxConfig, ShellConfig};
1+
use super::schema::{Config, ExecSugid, FilesystemConfig, SandboxConfig, ShellConfig};
22
use std::collections::HashSet;
33

44
/// Merge two configurations, with project taking precedence
@@ -47,6 +47,19 @@ fn merge_sandbox(global: &SandboxConfig, project: &SandboxConfig) -> SandboxConf
4747
// Merge profiles (using pre-filtered global_profiles)
4848
profiles: merge_unique_strings(&global_profiles, &project.profiles),
4949
network: project.network,
50+
allow_exec_sugid: merge_exec_sugid(&global.allow_exec_sugid, &project.allow_exec_sugid),
51+
}
52+
}
53+
54+
/// Merge ExecSugid: if both are Paths, union-merge; otherwise project overrides global.
55+
fn merge_exec_sugid(global: &ExecSugid, project: &ExecSugid) -> ExecSugid {
56+
// If project is default (deny all), keep global
57+
if project.is_default() && !global.is_default() {
58+
return global.clone();
59+
}
60+
match (global, project) {
61+
(ExecSugid::Paths(g), ExecSugid::Paths(p)) => ExecSugid::Paths(merge_unique_strings(g, p)),
62+
_ => project.clone(),
5063
}
5164
}
5265

src/config/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ pub use profile::{
1111
ProfileFilesystem, ProfileShell,
1212
};
1313
pub use project::load_project_config;
14-
pub use schema::{Config, NetworkMode};
14+
pub use schema::{Config, ExecSugid, NetworkMode};

src/config/profile.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::config::schema::NetworkMode;
1+
use crate::config::schema::{ExecSugid, NetworkMode};
22
use serde::{Deserialize, Serialize};
33
use std::collections::HashSet;
44
use std::path::Path;
@@ -64,6 +64,9 @@ pub struct Profile {
6464
/// Raw seatbelt rules (advanced)
6565
#[serde(default)]
6666
pub seatbelt: Option<ProfileSeatbelt>,
67+
/// Allow execution of setuid/setgid binaries
68+
#[serde(default)]
69+
pub allow_exec_sugid: Option<ExecSugid>,
6770
}
6871

6972
/// Profile filesystem configuration
@@ -275,6 +278,25 @@ pub fn compose_profiles(profiles: &[Profile]) -> Profile {
275278
// Shell: merge unique env vars
276279
merge_unique(&mut result.shell.pass_env, &profile.shell.pass_env);
277280
merge_unique(&mut result.shell.deny_env, &profile.shell.deny_env);
281+
282+
// ExecSugid: Paths union-merge, otherwise last-set wins
283+
if let Some(incoming) = &profile.allow_exec_sugid {
284+
match (&result.allow_exec_sugid, incoming) {
285+
(Some(ExecSugid::Paths(existing)), ExecSugid::Paths(new)) => {
286+
let mut merged = existing.clone();
287+
let existing_set: HashSet<&str> = existing.iter().map(|s| s.as_str()).collect();
288+
for path in new {
289+
if !existing_set.contains(path.as_str()) {
290+
merged.push(path.clone());
291+
}
292+
}
293+
result.allow_exec_sugid = Some(ExecSugid::Paths(merged));
294+
}
295+
_ => {
296+
result.allow_exec_sugid = Some(incoming.clone());
297+
}
298+
}
299+
}
278300
}
279301

280302
result

0 commit comments

Comments
 (0)