From 7fdb22810399e6c6864bfef63fb82b2f27d04952 Mon Sep 17 00:00:00 2001 From: Jared Forsyth Date: Tue, 1 Sep 2026 13:46:38 -0500 Subject: [PATCH 1/2] Add a 'deny-write' option In my applicaiton, I want to run commands without granting write access to the working directory, which there didn't seem to be an option to make that work. I thought about doing a `--dont-auto-allow-cwd` or something like that, but I think `--deny-write` is more generally applicable, as it also works for when you want to exclude writes to a subset of an explicitly allowed file tree. --- README.md | 1 + shell/sx.bash | 2 +- shell/sx.fish | 1 + shell/sx.zsh | 1 + src/cli/args.rs | 4 ++++ src/cli/commands.rs | 18 ++++++++++++++++++ src/config/merge.rs | 1 + src/config/profile.rs | 5 +++++ src/config/schema.rs | 2 ++ src/sandbox/seatbelt.rs | 19 +++++++++++++++++++ tests/integration.rs | 2 ++ 11 files changed, 55 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 31778d6..3efc051 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ sx --dry-run rust # Preview seatbelt profile | `--allow-read ` | Allow read | | `--allow-write ` | Allow write | | `--deny-read ` | Deny read (overrides allows) | +| `--deny-write ` | Deny write (override allows) | | `--trace` shows violations from *all* sandboxed processes on the system, not just yours. macOS limitation. diff --git a/shell/sx.bash b/shell/sx.bash index 902449b..88feea1 100644 --- a/shell/sx.bash +++ b/shell/sx.bash @@ -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")) diff --git a/shell/sx.fish b/shell/sx.fish index 9ca1ad9..8f4a734 100644 --- a/shell/sx.fish +++ b/shell/sx.fish @@ -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' diff --git a/shell/sx.zsh b/shell/sx.zsh index 82ac94e..669e47d 100644 --- a/shell/sx.zsh +++ b/shell/sx.zsh @@ -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 diff --git a/src/cli/args.rs b/src/cli/args.rs index 72c4af7..7f7166a 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -83,6 +83,10 @@ pub struct Args { #[arg(long = "deny-read", value_name = "PATH")] pub deny_read: Vec, + /// Deny write access to path + #[arg(long = "deny-write", value_name = "PATH")] + pub deny_write: Vec, + /// 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, diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 76a97ad..92887e7 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -295,6 +295,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(); @@ -337,6 +338,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()) @@ -361,6 +366,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, @@ -432,6 +438,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 { + 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 { let mut paths = Vec::new(); @@ -502,6 +517,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 diff --git a/src/config/merge.rs b/src/config/merge.rs index 3d764ac..215cc1c 100644 --- a/src/config/merge.rs +++ b/src/config/merge.rs @@ -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), } diff --git a/src/config/profile.rs b/src/config/profile.rs index 28be4e5..c1653fd 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -80,6 +80,7 @@ pub struct ProfileFilesystem { pub allow_read: Vec, pub deny_read: Vec, pub allow_write: Vec, + pub deny_write: Vec, /// Paths to allow directory listing only (readdir), not file contents pub allow_list_dirs: Vec, } @@ -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, diff --git a/src/config/schema.rs b/src/config/schema.rs index a986a80..36d46be 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -102,6 +102,8 @@ pub struct FilesystemConfig { pub deny_read: Vec, /// Paths to always allow writing (beyond project dir) pub allow_write: Vec, + /// Paths to always deny writing (override allows) + pub deny_write: Vec, /// 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 diff --git a/src/sandbox/seatbelt.rs b/src/sandbox/seatbelt.rs index e1b939d..83d8997 100644 --- a/src/sandbox/seatbelt.rs +++ b/src/sandbox/seatbelt.rs @@ -98,6 +98,8 @@ pub struct SandboxParams { pub deny_read: Vec, /// Paths to allow writing (restricted by default) pub allow_write: Vec, + /// Paths to explicitly deny writing (overrides allow_write, for sensitive subpaths) + pub deny_write: Vec, /// 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. @@ -222,6 +224,23 @@ pub fn generate_seatbelt_profile(params: &SandboxParams) -> Result SandboxParams { PathBuf::from("/sbin"), ], deny_read: vec![], + deny_write: vec![], allow_write: vec![], allow_list_dirs: vec![], raw_rules: None, @@ -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, From a958e865d8303343459c6207a39f401d0113293c Mon Sep 17 00:00:00 2001 From: Jared Forsyth Date: Tue, 8 Sep 2026 12:33:53 -0500 Subject: [PATCH 2/2] feedback --- README.md | 2 +- src/cli/commands.rs | 9 +++++++ src/sandbox/seatbelt.rs | 56 ++++++++++++++++++++++++++++------------- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 3efc051..445923f 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ sx --dry-run rust # Preview seatbelt profile | `--allow-read ` | Allow read | | `--allow-write ` | Allow write | | `--deny-read ` | Deny read (overrides allows) | -| `--deny-write ` | Deny write (override allows) | +| `--deny-write ` | Deny write (overrides allows) | | `--trace` shows violations from *all* sandboxed processes on the system, not just yours. macOS limitation. diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 92887e7..cb50261 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -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):"); diff --git a/src/sandbox/seatbelt.rs b/src/sandbox/seatbelt.rs index 83d8997..39291cc 100644 --- a/src/sandbox/seatbelt.rs +++ b/src/sandbox/seatbelt.rs @@ -224,23 +224,6 @@ pub fn generate_seatbelt_profile(params: &SandboxParams) -> Result Result 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 {