From 722c2073cbb5c183998a59f8ab9ab0297d7909e0 Mon Sep 17 00:00:00 2001 From: Pierre Tomasina Date: Mon, 2 Feb 2026 16:05:33 +0800 Subject: [PATCH 1/3] feat(sandbox): add allow_list_dirs for Bun runtime compatibility - Add allow_list_dirs config to FilesystemConfig for directory listing only - Use Seatbelt literal filter for readdir without file/subdir access - Add built-in bun profile with parent directory listing enabled - Auto-detect Bun projects via bun.lockb and bunfig.toml markers - Add comprehensive tests for allow_list_dirs behavior Fixes #13 --- profiles/bun.toml | 44 ++++++++++++++++++ src/cli/commands.rs | 42 +++++++++++++++++ src/config/merge.rs | 1 + src/config/profile.rs | 10 ++++ src/config/schema.rs | 5 ++ src/detection/project_type.rs | 7 +++ src/sandbox/seatbelt.rs | 88 +++++++++++++++++++++++++++++++++++ tests/integration.rs | 2 + 8 files changed, 199 insertions(+) create mode 100644 profiles/bun.toml diff --git a/profiles/bun.toml b/profiles/bun.toml new file mode 100644 index 0000000..1512ceb --- /dev/null +++ b/profiles/bun.toml @@ -0,0 +1,44 @@ +# Bun runtime profile +# +# Enables directory listing on parent directories required by Bun's +# module resolver during initialization. Bun uses readdir() on parent +# directories (up to /Users and ~/) to pre-populate its directory cache +# for fast module resolution. +# +# This profile allows listing directory contents without granting access +# to read files or subdirectories within them. +# +# Security note: This exposes directory names in /Users (usernames) and ~/ +# (home directory contents) but NOT file contents. Use deny_read to protect +# sensitive subdirectories like ~/pCloud or ~/.ssh. + +network_mode = "online" + +[filesystem] +# Bun installation and cache +allow_read = [ + "~/.bun", +] + +# Allow listing parent directories for Bun's module resolution +# Uses Seatbelt 'literal' filter - only the exact directory is listable +allow_list_dirs = [ + "/Users", + "~", +] + +# Deny access to sensitive directories even if parent listing is allowed +# Add your sensitive paths here +deny_read = [ + "~/.ssh", + "~/.gnupg", + "~/.aws", + "~/.config/gh", +] + +[shell] +pass_env = [ + "BUN_INSTALL", + "NODE_ENV", + "npm_config_registry", +] diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 80e688d..da58e3c 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -67,6 +67,15 @@ pub fn explain(args: &Args) -> Result<()> { println!(); } + // Directory listing only paths + if !context.params.allow_list_dirs.is_empty() { + println!("Directory Listing Only (readdir without file access):"); + for path in &context.params.allow_list_dirs { + println!(" ~ {}", path.display()); + } + println!(); + } + // Allowed write paths if !context.params.allow_write.is_empty() { println!("Allowed Write Paths:"); @@ -260,6 +269,20 @@ 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 allow_list_dirs = collect_allow_list_dirs_paths(config, profile); + + // If allow_list_dirs is configured, add all parent directories of working_dir + // This is needed for runtimes like Bun that scan ALL parent directories + if !allow_list_dirs.is_empty() { + let mut parent = working_dir.parent(); + while let Some(p) = parent { + let path_str = p.to_string_lossy().to_string(); + if !path_str.is_empty() && path_str != "/" && !allow_list_dirs.contains(&path_str) { + allow_list_dirs.push(path_str); + } + parent = p.parent(); + } + } // Expand all paths allow_read = expand_paths(&allow_read) @@ -274,6 +297,10 @@ fn build_sandbox_params( .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()) + .collect(); // Build raw rules if present let raw_rules = profile.seatbelt.as_ref().and_then(|s| s.raw.clone()); @@ -285,6 +312,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(), + allow_list_dirs: allow_list_dirs.into_iter().map(PathBuf::from).collect(), raw_rules, } } @@ -335,6 +363,14 @@ fn collect_allow_write_paths(config: &Config, profile: &Profile, cli: &[String]) 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(); + paths.extend(config.filesystem.allow_list_dirs.iter().cloned()); + paths.extend(profile.filesystem.allow_list_dirs.iter().cloned()); + paths +} + /// Generate the default .sandbox.toml template fn generate_config_template() -> &'static str { r#"# .sandbox.toml @@ -362,6 +398,12 @@ allow_write = [] # Paths to deny even if globally allowed deny_read = [] +# 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 +# without reading files or subdirectories within them. +allow_list_dirs = [] + [shell] # Additional environment variables to pass through pass_env = [] diff --git a/src/config/merge.rs b/src/config/merge.rs index a970712..e861174 100644 --- a/src/config/merge.rs +++ b/src/config/merge.rs @@ -55,6 +55,7 @@ fn merge_filesystem(global: &FilesystemConfig, project: &FilesystemConfig) -> Fi allow_read: merge_unique_strings(&global.allow_read, &project.allow_read), deny_read: merge_unique_strings(&global.deny_read, &project.deny_read), 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 70cd62d..8dd4550 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -73,6 +73,8 @@ pub struct ProfileFilesystem { pub allow_read: Vec, pub deny_read: Vec, pub allow_write: Vec, + /// Paths to allow directory listing only (readdir), not file contents + pub allow_list_dirs: Vec, } /// Profile shell configuration @@ -99,6 +101,7 @@ pub enum BuiltinProfile { Rust, Claude, Gpg, + Bun, } impl BuiltinProfile { @@ -111,6 +114,7 @@ impl BuiltinProfile { "rust" => Some(Self::Rust), "claude" => Some(Self::Claude), "gpg" => Some(Self::Gpg), + "bun" => Some(Self::Bun), _ => None, } } @@ -124,6 +128,7 @@ impl BuiltinProfile { Self::Rust => "rust", Self::Claude => "claude", Self::Gpg => "gpg", + Self::Bun => "bun", } } @@ -140,6 +145,7 @@ impl BuiltinProfile { Self::Rust => include_str!("../../profiles/rust.toml"), Self::Claude => include_str!("../../profiles/claude.toml"), Self::Gpg => include_str!("../../profiles/gpg.toml"), + Self::Bun => include_str!("../../profiles/bun.toml"), }; toml::from_str(toml_str).map_err(|e| ProfileError::InvalidBuiltin { name: self.name(), @@ -257,6 +263,10 @@ pub fn compose_profiles(profiles: &[Profile]) -> Profile { &mut result.filesystem.allow_write, &profile.filesystem.allow_write, ); + merge_unique( + &mut result.filesystem.allow_list_dirs, + &profile.filesystem.allow_list_dirs, + ); // Shell: merge unique env vars merge_unique(&mut result.shell.pass_env, &profile.shell.pass_env); diff --git a/src/config/schema.rs b/src/config/schema.rs index 59b9e44..419cc48 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -74,6 +74,11 @@ pub struct FilesystemConfig { pub deny_read: Vec, /// Paths to always allow writing (beyond project dir) pub allow_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 + /// parent directories during module resolution. + pub allow_list_dirs: Vec, } /// Shell environment configuration diff --git a/src/detection/project_type.rs b/src/detection/project_type.rs index 557ab71..5dae501 100644 --- a/src/detection/project_type.rs +++ b/src/detection/project_type.rs @@ -10,6 +10,7 @@ pub enum ProjectType { Python, Rust, Go, + Bun, } impl ProjectType { @@ -20,6 +21,7 @@ impl ProjectType { ProjectType::Python => "python", ProjectType::Rust => "rust", ProjectType::Go => "go", + ProjectType::Bun => "bun", } } @@ -31,6 +33,7 @@ impl ProjectType { /// Get all known project types fn all() -> &'static [ProjectType] { &[ + ProjectType::Bun, // Check Bun first (more specific than Node) ProjectType::Node, ProjectType::Python, ProjectType::Rust, @@ -45,6 +48,7 @@ impl ProjectType { ProjectType::Python => &["requirements.txt", "pyproject.toml", "setup.py"], ProjectType::Rust => &["Cargo.toml"], ProjectType::Go => &["go.mod"], + ProjectType::Bun => &["bun.lockb", "bunfig.toml"], } } } @@ -86,6 +90,7 @@ mod tests { assert_eq!(ProjectType::Python.as_str(), "python"); assert_eq!(ProjectType::Rust.as_str(), "rust"); assert_eq!(ProjectType::Go.as_str(), "go"); + assert_eq!(ProjectType::Bun.as_str(), "bun"); } #[test] @@ -95,5 +100,7 @@ mod tests { assert!(ProjectType::Python.markers().contains(&"pyproject.toml")); assert!(ProjectType::Rust.markers().contains(&"Cargo.toml")); assert!(ProjectType::Go.markers().contains(&"go.mod")); + assert!(ProjectType::Bun.markers().contains(&"bun.lockb")); + assert!(ProjectType::Bun.markers().contains(&"bunfig.toml")); } } diff --git a/src/sandbox/seatbelt.rs b/src/sandbox/seatbelt.rs index ea33aba..f70c1a1 100644 --- a/src/sandbox/seatbelt.rs +++ b/src/sandbox/seatbelt.rs @@ -98,6 +98,10 @@ pub struct SandboxParams { pub deny_read: Vec, /// Paths to allow writing (restricted by default) pub allow_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. + pub allow_list_dirs: Vec, /// Raw seatbelt rules to include verbatim pub raw_rules: Option, } @@ -158,6 +162,20 @@ pub fn generate_seatbelt_profile(params: &SandboxParams) -> Result list_pos, + "deny rules must come after allow_list_dirs for Seatbelt last-match-wins semantics" + ); + } + + #[test] + fn test_allow_list_dirs_section_comment() { + let params = SandboxParams { + allow_list_dirs: vec![PathBuf::from("/Users")], + ..Default::default() + }; + let profile = generate_seatbelt_profile(¶ms).unwrap(); + assert!(profile.contains("; Directory listing only")); + } } diff --git a/tests/integration.rs b/tests/integration.rs index 1f37e26..cf961b0 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -81,6 +81,7 @@ fn fs_sandbox_params(working_dir: PathBuf) -> SandboxParams { ], deny_read: vec![], allow_write: vec![], + allow_list_dirs: vec![], raw_rules: None, } } @@ -256,6 +257,7 @@ fn network_sandbox_params(working_dir: PathBuf, mode: NetworkMode) -> SandboxPar ], deny_read: vec![], allow_write: vec![], + allow_list_dirs: vec![], raw_rules: None, } } From 4a93064fcbb4e7109ae04453503e4d84d0469801 Mon Sep 17 00:00:00 2001 From: Pierre Tomasina Date: Mon, 2 Feb 2026 16:57:39 +0800 Subject: [PATCH 2/3] style: fmt --- src/sandbox/seatbelt.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/sandbox/seatbelt.rs b/src/sandbox/seatbelt.rs index f70c1a1..21f76f7 100644 --- a/src/sandbox/seatbelt.rs +++ b/src/sandbox/seatbelt.rs @@ -171,7 +171,9 @@ pub fn generate_seatbelt_profile(params: &SandboxParams) -> Result Date: Mon, 2 Feb 2026 17:12:25 +0800 Subject: [PATCH 3/3] feat(bun): make profile configurable and add sxb alias - Remove hardcoded network_mode to let users choose - Add ~/.bun to allow_write for cache updates - Remove redundant deny_read rules (covered by base) - Add sxb alias to zsh, bash, fish integrations --- profiles/bun.toml | 15 ++++----------- src/shell/integration.rs | 3 +++ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/profiles/bun.toml b/profiles/bun.toml index 1512ceb..25e62da 100644 --- a/profiles/bun.toml +++ b/profiles/bun.toml @@ -12,14 +12,16 @@ # (home directory contents) but NOT file contents. Use deny_read to protect # sensitive subdirectories like ~/pCloud or ~/.ssh. -network_mode = "online" - [filesystem] # Bun installation and cache allow_read = [ "~/.bun", ] +allow_write = [ + "~/.bun", +] + # Allow listing parent directories for Bun's module resolution # Uses Seatbelt 'literal' filter - only the exact directory is listable allow_list_dirs = [ @@ -27,15 +29,6 @@ allow_list_dirs = [ "~", ] -# Deny access to sensitive directories even if parent listing is allowed -# Add your sensitive paths here -deny_read = [ - "~/.ssh", - "~/.gnupg", - "~/.aws", - "~/.config/gh", -] - [shell] pass_env = [ "BUN_INSTALL", diff --git a/src/shell/integration.rs b/src/shell/integration.rs index bb05ef8..fe92aeb 100644 --- a/src/shell/integration.rs +++ b/src/shell/integration.rs @@ -112,6 +112,7 @@ alias sxo='sx online' alias sxl='sx localhost' alias sxr='sx online rust' alias sxc='sx online claude' +alias sxb='sx online bun' "#; const BASH_INTEGRATION: &str = r#"# sx.bash - Bash integration for sandbox CLI @@ -159,6 +160,7 @@ alias sxo='sx online' alias sxl='sx localhost' alias sxr='sx online rust' alias sxc='sx online claude' +alias sxb='sx online bun' "#; const FISH_INTEGRATION: &str = r#"# sx.fish - Fish integration for sandbox CLI @@ -216,6 +218,7 @@ alias sxo 'sx online' alias sxl 'sx localhost' alias sxr 'sx online rust' alias sxc 'sx online claude' +alias sxb 'sx online bun' "#; #[cfg(test)]