Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ sx --dry-run rust # Preview seatbelt profile
| `--allow-read <PATH>` | Allow read |
| `--allow-write <PATH>` | Allow write |
| `--deny-read <PATH>` | Deny read (overrides allows) |
| `--deny-write <PATH>` | Deny write (overrides allows) |

| `--trace` shows violations from *all* sandboxed processes on the system, not just yours. macOS limitation.

Expand Down
2 changes: 1 addition & 1 deletion shell/sx.bash
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ fi
_sx_completions() {
local cur="${COMP_WORDS[COMP_CWORD]}"
local profiles="base online localhost rust bun claude gpg"
local options="--help --version --verbose --debug --trace --trace-file --dry-run --config --no-config --explain --init --offline --online --localhost --allow-read --allow-write --deny-read"
local options="--help --version --verbose --debug --trace --trace-file --dry-run --config --no-config --explain --init --offline --online --localhost --allow-read --allow-write --deny-read --deny-write"

if [[ "$cur" == -* ]]; then
COMPREPLY=($(compgen -W "$options" -- "$cur"))
Expand Down
1 change: 1 addition & 0 deletions shell/sx.fish
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ complete -c sx -l localhost -d 'Allow localhost only'
complete -c sx -l allow-read -d 'Allow read access to path'
complete -c sx -l allow-write -d 'Allow write access to path'
complete -c sx -l deny-read -d 'Deny read access to path'
complete -c sx -l deny-write -d 'Deny write access to path'

# Profile completions
complete -c sx -a 'base' -d 'Minimal sandbox'
Expand Down
1 change: 1 addition & 0 deletions shell/sx.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ _sx() {
'*--allow-read=[Allow read access]:path:_files' \
'*--allow-write=[Allow write access]:path:_files' \
'*--deny-read=[Deny read access]:path:_files' \
'*--deny-write=[Deny write access]:path:_files' \
'*:: :->args'

case $state in
Expand Down
4 changes: 4 additions & 0 deletions src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ pub struct Args {
#[arg(long = "deny-read", value_name = "PATH")]
pub deny_read: Vec<String>,

/// Deny write access to path
#[arg(long = "deny-write", value_name = "PATH")]
pub deny_write: Vec<String>,

/// Allow execution of setuid/setgid binary at PATH (e.g., /bin/ps)
#[arg(long = "allow-exec-sugid", value_name = "PATH")]
pub allow_exec_sugid: Vec<String>,
Expand Down
27 changes: 27 additions & 0 deletions src/cli/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ pub fn explain(args: &Args) -> Result<()> {
println!();
}

// Denied write paths
if !context.params.deny_write.is_empty() {
println!("Denied Write Paths:");
for path in &context.params.deny_write {
println!(" - {}", path.display());
}
println!();
}

// Directory listing only paths
if !context.params.allow_list_dirs.is_empty() {
println!("Directory Listing Only (readdir without file access):");
Expand Down Expand Up @@ -295,6 +304,7 @@ fn build_sandbox_params(
let mut allow_read = collect_allow_read_paths(config, profile, &args.allow_read);
let mut deny_read = collect_deny_read_paths(config, profile, &args.deny_read);
let mut allow_write = collect_allow_write_paths(config, profile, &args.allow_write);
let mut deny_write = collect_deny_write_paths(config, profile, &args.deny_write);
let mut allow_list_dirs = collect_allow_list_dirs_paths(config, profile);
let has_configured_list_dirs = !allow_list_dirs.is_empty();

Expand Down Expand Up @@ -337,6 +347,10 @@ fn build_sandbox_params(
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
deny_write = expand_paths(&deny_write)
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
allow_list_dirs = expand_paths(&allow_list_dirs)
.into_iter()
.map(|p| p.to_string_lossy().to_string())
Expand All @@ -361,6 +375,7 @@ fn build_sandbox_params(
allow_read: allow_read.into_iter().map(PathBuf::from).collect(),
deny_read: deny_read.into_iter().map(PathBuf::from).collect(),
allow_write: allow_write.into_iter().map(PathBuf::from).collect(),
deny_write: deny_write.into_iter().map(PathBuf::from).collect(),
allow_list_dirs: allow_list_dirs.into_iter().map(PathBuf::from).collect(),
raw_rules,
allow_exec_sugid,
Expand Down Expand Up @@ -432,6 +447,15 @@ fn collect_allow_write_paths(config: &Config, profile: &Profile, cli: &[String])
paths
}

/// Collect deny-write paths from config, profile, and CLI
fn collect_deny_write_paths(config: &Config, profile: &Profile, cli: &[String]) -> Vec<String> {
let mut paths = Vec::new();
paths.extend(config.filesystem.deny_write.iter().cloned());
paths.extend(profile.filesystem.deny_write.iter().cloned());
paths.extend(cli.iter().cloned());
paths
}

/// Collect allow-list-dirs paths from config and profile (directory listing only)
fn collect_allow_list_dirs_paths(config: &Config, profile: &Profile) -> Vec<String> {
let mut paths = Vec::new();
Expand Down Expand Up @@ -502,6 +526,9 @@ allow_write = []
# Paths to deny even if globally allowed
deny_read = []

# Paths to deny even if globally allowed
deny_write = []

# Directories to allow listing (readdir) but not file access inside.
# Useful for runtimes like Bun that scan parent directories.
# Example: ["/Users", "~"] allows listing these directories' contents
Expand Down
1 change: 1 addition & 0 deletions src/config/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ fn merge_filesystem(global: &FilesystemConfig, project: &FilesystemConfig) -> Fi
FilesystemConfig {
allow_read: merge_unique_strings(&global.allow_read, &project.allow_read),
deny_read: merge_unique_strings(&global.deny_read, &project.deny_read),
deny_write: merge_unique_strings(&global.deny_write, &project.deny_write),
allow_write: merge_unique_strings(&global.allow_write, &project.allow_write),
allow_list_dirs: merge_unique_strings(&global.allow_list_dirs, &project.allow_list_dirs),
}
Expand Down
5 changes: 5 additions & 0 deletions src/config/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ pub struct ProfileFilesystem {
pub allow_read: Vec<String>,
pub deny_read: Vec<String>,
pub allow_write: Vec<String>,
pub deny_write: Vec<String>,
/// Paths to allow directory listing only (readdir), not file contents
pub allow_list_dirs: Vec<String>,
}
Expand Down Expand Up @@ -232,6 +233,10 @@ pub fn compose_profiles(profiles: &[Profile]) -> Profile {
&mut result.filesystem.allow_write,
&profile.filesystem.allow_write,
);
merge_unique(
&mut result.filesystem.deny_write,
&profile.filesystem.deny_write,
);
merge_unique(
&mut result.filesystem.allow_list_dirs,
&profile.filesystem.allow_list_dirs,
Expand Down
2 changes: 2 additions & 0 deletions src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ pub struct FilesystemConfig {
pub deny_read: Vec<String>,
/// Paths to always allow writing (beyond project dir)
pub allow_write: Vec<String>,
/// Paths to always deny writing (override allows)
pub deny_write: Vec<String>,
/// Paths to allow directory listing only (readdir), not file contents.
/// Uses Seatbelt `literal` filter - only the exact directory is listable,
/// not its children. Useful for runtimes like Bun that need to scan
Expand Down
41 changes: 41 additions & 0 deletions src/sandbox/seatbelt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ pub struct SandboxParams {
pub deny_read: Vec<PathBuf>,
/// Paths to allow writing (restricted by default)
pub allow_write: Vec<PathBuf>,
/// Paths to explicitly deny writing (overrides allow_write, for sensitive subpaths)
pub deny_write: Vec<PathBuf>,
/// Paths to allow directory listing only (readdir), not file contents.
/// Uses Seatbelt `literal` filter - allows listing a directory's entries
/// without granting access to files or subdirectories within it.
Expand Down Expand Up @@ -251,6 +253,23 @@ pub fn generate_seatbelt_profile(params: &SandboxParams) -> Result<String, Seatb
profile.push('\n');
}

// Deny sensitive write paths (overrides allow_write for nested sensitive paths)
// Uses last-match-wins: deny after allow takes precedence
if !params.deny_write.is_empty() {
profile.push_str("; Denied write paths (sensitive data)\n");
for path in &params.deny_write {
let p = path.display().to_string();
let validated = validate_seatbelt_path(&p)?;
if contains_glob(validated) {
let regex = glob_to_regex(validated);
profile.push_str(&format!("(deny file-write* (regex #\"{regex}\"))\n"));
} else {
profile.push_str(&format!("(deny file-write* (subpath \"{validated}\"))\n"));
}
}
profile.push('\n');
}

// Device access - restricted to specific devices needed for shell/terminal operation
profile.push_str("; Device access\n");
profile.push_str("(allow file-read-data (literal \"/dev\"))\n");
Expand Down Expand Up @@ -558,6 +577,28 @@ mod tests {
);
}

#[test]
fn test_deny_rules_come_after_allow_write() {
let params = SandboxParams {
allow_write: vec![PathBuf::from("/home")],
deny_write: vec![PathBuf::from("/home/.config")],
..Default::default()
};
let profile = generate_seatbelt_profile(&params).unwrap();

let deny_pos = profile
.find("(deny file-write* (subpath \"/home/.config\"))")
.expect("deny rule should exist");
let allow_pos = profile
.find("(allow file-write* (subpath \"/home\"))")
.expect("allow rule should exist");

assert!(
deny_pos > allow_pos,
"deny rules must come after allow rules for Seatbelt last-match-wins semantics"
);
}

#[test]
fn test_working_dir_has_full_access() {
let params = SandboxParams {
Expand Down
2 changes: 2 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ fn fs_sandbox_params(working_dir: PathBuf) -> SandboxParams {
PathBuf::from("/sbin"),
],
deny_read: vec![],
deny_write: vec![],
allow_write: vec![],
allow_list_dirs: vec![],
raw_rules: None,
Expand Down Expand Up @@ -317,6 +318,7 @@ fn network_sandbox_params(working_dir: PathBuf, mode: NetworkMode) -> SandboxPar
PathBuf::from("/private/etc"),
],
deny_read: vec![],
deny_write: vec![],
allow_write: vec![],
allow_list_dirs: vec![],
raw_rules: None,
Expand Down