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
37 changes: 37 additions & 0 deletions profiles/bun.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 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.

[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 = [
"/Users",
"~",
]

[shell]
pass_env = [
"BUN_INSTALL",
"NODE_ENV",
"npm_config_registry",
]
42 changes: 42 additions & 0 deletions src/cli/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:");
Expand Down Expand Up @@ -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)
Expand All @@ -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());
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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<String> {
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
Expand Down Expand Up @@ -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 = []
Expand Down
1 change: 1 addition & 0 deletions src/config/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/config/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ pub struct ProfileFilesystem {
pub allow_read: Vec<String>,
pub deny_read: Vec<String>,
pub allow_write: Vec<String>,
/// Paths to allow directory listing only (readdir), not file contents
pub allow_list_dirs: Vec<String>,
}

/// Profile shell configuration
Expand All @@ -99,6 +101,7 @@ pub enum BuiltinProfile {
Rust,
Claude,
Gpg,
Bun,
}

impl BuiltinProfile {
Expand All @@ -111,6 +114,7 @@ impl BuiltinProfile {
"rust" => Some(Self::Rust),
"claude" => Some(Self::Claude),
"gpg" => Some(Self::Gpg),
"bun" => Some(Self::Bun),
_ => None,
}
}
Expand All @@ -124,6 +128,7 @@ impl BuiltinProfile {
Self::Rust => "rust",
Self::Claude => "claude",
Self::Gpg => "gpg",
Self::Bun => "bun",
}
}

Expand All @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ pub struct FilesystemConfig {
pub deny_read: Vec<String>,
/// Paths to always allow writing (beyond project dir)
pub allow_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
/// parent directories during module resolution.
pub allow_list_dirs: Vec<String>,
}

/// Shell environment configuration
Expand Down
7 changes: 7 additions & 0 deletions src/detection/project_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub enum ProjectType {
Python,
Rust,
Go,
Bun,
}

impl ProjectType {
Expand All @@ -20,6 +21,7 @@ impl ProjectType {
ProjectType::Python => "python",
ProjectType::Rust => "rust",
ProjectType::Go => "go",
ProjectType::Bun => "bun",
}
}

Expand All @@ -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,
Expand All @@ -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"],
}
}
}
Expand Down Expand Up @@ -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]
Expand All @@ -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"));
}
}
87 changes: 87 additions & 0 deletions src/sandbox/seatbelt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ pub struct SandboxParams {
pub deny_read: Vec<PathBuf>,
/// Paths to allow writing (restricted by default)
pub allow_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.
pub allow_list_dirs: Vec<PathBuf>,
/// Raw seatbelt rules to include verbatim
pub raw_rules: Option<String>,
}
Expand Down Expand Up @@ -158,6 +162,22 @@ pub fn generate_seatbelt_profile(params: &SandboxParams) -> Result<String, Seatb
}
profile.push('\n');

// Directory listing only (readdir) - uses literal filter
// Allows listing directory contents without reading files or subdirectories.
// Useful for runtimes like Bun that scan parent directories during module resolution.
if !params.allow_list_dirs.is_empty() {
profile.push_str("; Directory listing only (readdir without file access)\n");
for path in &params.allow_list_dirs {
let p = path.display().to_string();
let validated = validate_seatbelt_path(&p)?;
// Use literal filter - only matches the exact path, not children
profile.push_str(&format!(
"(allow file-read-data (literal \"{validated}\"))\n"
));
}
profile.push('\n');
}

// Deny sensitive paths (overrides allow_read for nested sensitive paths)
// Uses last-match-wins: deny after allow takes precedence
if !params.deny_read.is_empty() {
Expand Down Expand Up @@ -532,4 +552,71 @@ mod tests {
// seatbelt doesn't accept IP addresses, only "localhost" or "*"
assert!(!profile.contains("127.0.0.1"));
}

// === Directory Listing (allow_list_dirs) Tests ===

#[test]
fn test_allow_list_dirs_uses_literal() {
let params = SandboxParams {
allow_list_dirs: vec![PathBuf::from("/Users")],
..Default::default()
};
let profile = generate_seatbelt_profile(&params).unwrap();
// Should use literal filter (exact match only), not subpath
assert!(
profile.contains(r#"(allow file-read-data (literal "/Users"))"#),
"allow_list_dirs should use literal filter, got:\n{}",
profile
);
// Should NOT use subpath (which would allow reading all contents)
assert!(
!profile.contains(r#"(allow file-read* (subpath "/Users"))"#),
"allow_list_dirs should NOT use subpath filter"
);
}

#[test]
fn test_allow_list_dirs_multiple_paths() {
let params = SandboxParams {
allow_list_dirs: vec![PathBuf::from("/Users"), PathBuf::from("/Users/testuser")],
..Default::default()
};
let profile = generate_seatbelt_profile(&params).unwrap();
assert!(profile.contains(r#"(allow file-read-data (literal "/Users"))"#));
assert!(profile.contains(r#"(allow file-read-data (literal "/Users/testuser"))"#));
}

#[test]
fn test_allow_list_dirs_with_deny_read() {
// Verify deny_read still takes precedence over allow_list_dirs
let params = SandboxParams {
allow_list_dirs: vec![PathBuf::from("/Users/testuser")],
deny_read: vec![PathBuf::from("/Users/testuser/secret")],
..Default::default()
};
let profile = generate_seatbelt_profile(&params).unwrap();

let list_pos = profile
.find(r#"(allow file-read-data (literal "/Users/testuser"))"#)
.expect("allow_list_dirs rule should exist");
let deny_pos = profile
.find(r#"(deny file-read* (subpath "/Users/testuser/secret"))"#)
.expect("deny rule should exist");

// deny_read comes after allow_list_dirs (last-match-wins)
assert!(
deny_pos > 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(&params).unwrap();
assert!(profile.contains("; Directory listing only"));
}
}
3 changes: 3 additions & 0 deletions src/shell/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)]
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 {
],
deny_read: vec![],
allow_write: vec![],
allow_list_dirs: vec![],
raw_rules: None,
}
}
Expand Down Expand Up @@ -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,
}
}
Expand Down