Skip to content

Commit 03ba5ad

Browse files
authored
feat: add allow_list_dirs for Bun runtime compatibility (#17)
* 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 * style: fmt * 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
1 parent 7f3c588 commit 03ba5ad

9 files changed

Lines changed: 194 additions & 0 deletions

File tree

profiles/bun.toml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Bun runtime profile
2+
#
3+
# Enables directory listing on parent directories required by Bun's
4+
# module resolver during initialization. Bun uses readdir() on parent
5+
# directories (up to /Users and ~/) to pre-populate its directory cache
6+
# for fast module resolution.
7+
#
8+
# This profile allows listing directory contents without granting access
9+
# to read files or subdirectories within them.
10+
#
11+
# Security note: This exposes directory names in /Users (usernames) and ~/
12+
# (home directory contents) but NOT file contents. Use deny_read to protect
13+
# sensitive subdirectories like ~/pCloud or ~/.ssh.
14+
15+
[filesystem]
16+
# Bun installation and cache
17+
allow_read = [
18+
"~/.bun",
19+
]
20+
21+
allow_write = [
22+
"~/.bun",
23+
]
24+
25+
# Allow listing parent directories for Bun's module resolution
26+
# Uses Seatbelt 'literal' filter - only the exact directory is listable
27+
allow_list_dirs = [
28+
"/Users",
29+
"~",
30+
]
31+
32+
[shell]
33+
pass_env = [
34+
"BUN_INSTALL",
35+
"NODE_ENV",
36+
"npm_config_registry",
37+
]

src/cli/commands.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ pub fn explain(args: &Args) -> Result<()> {
6767
println!();
6868
}
6969

70+
// Directory listing only paths
71+
if !context.params.allow_list_dirs.is_empty() {
72+
println!("Directory Listing Only (readdir without file access):");
73+
for path in &context.params.allow_list_dirs {
74+
println!(" ~ {}", path.display());
75+
}
76+
println!();
77+
}
78+
7079
// Allowed write paths
7180
if !context.params.allow_write.is_empty() {
7281
println!("Allowed Write Paths:");
@@ -260,6 +269,20 @@ fn build_sandbox_params(
260269
let mut allow_read = collect_allow_read_paths(config, profile, &args.allow_read);
261270
let mut deny_read = collect_deny_read_paths(config, profile, &args.deny_read);
262271
let mut allow_write = collect_allow_write_paths(config, profile, &args.allow_write);
272+
let mut allow_list_dirs = collect_allow_list_dirs_paths(config, profile);
273+
274+
// If allow_list_dirs is configured, add all parent directories of working_dir
275+
// This is needed for runtimes like Bun that scan ALL parent directories
276+
if !allow_list_dirs.is_empty() {
277+
let mut parent = working_dir.parent();
278+
while let Some(p) = parent {
279+
let path_str = p.to_string_lossy().to_string();
280+
if !path_str.is_empty() && path_str != "/" && !allow_list_dirs.contains(&path_str) {
281+
allow_list_dirs.push(path_str);
282+
}
283+
parent = p.parent();
284+
}
285+
}
263286

264287
// Expand all paths
265288
allow_read = expand_paths(&allow_read)
@@ -274,6 +297,10 @@ fn build_sandbox_params(
274297
.into_iter()
275298
.map(|p| p.to_string_lossy().to_string())
276299
.collect();
300+
allow_list_dirs = expand_paths(&allow_list_dirs)
301+
.into_iter()
302+
.map(|p| p.to_string_lossy().to_string())
303+
.collect();
277304

278305
// Build raw rules if present
279306
let raw_rules = profile.seatbelt.as_ref().and_then(|s| s.raw.clone());
@@ -285,6 +312,7 @@ fn build_sandbox_params(
285312
allow_read: allow_read.into_iter().map(PathBuf::from).collect(),
286313
deny_read: deny_read.into_iter().map(PathBuf::from).collect(),
287314
allow_write: allow_write.into_iter().map(PathBuf::from).collect(),
315+
allow_list_dirs: allow_list_dirs.into_iter().map(PathBuf::from).collect(),
288316
raw_rules,
289317
}
290318
}
@@ -335,6 +363,14 @@ fn collect_allow_write_paths(config: &Config, profile: &Profile, cli: &[String])
335363
paths
336364
}
337365

366+
/// Collect allow-list-dirs paths from config and profile (directory listing only)
367+
fn collect_allow_list_dirs_paths(config: &Config, profile: &Profile) -> Vec<String> {
368+
let mut paths = Vec::new();
369+
paths.extend(config.filesystem.allow_list_dirs.iter().cloned());
370+
paths.extend(profile.filesystem.allow_list_dirs.iter().cloned());
371+
paths
372+
}
373+
338374
/// Generate the default .sandbox.toml template
339375
fn generate_config_template() -> &'static str {
340376
r#"# .sandbox.toml
@@ -362,6 +398,12 @@ allow_write = []
362398
# Paths to deny even if globally allowed
363399
deny_read = []
364400
401+
# Directories to allow listing (readdir) but not file access inside.
402+
# Useful for runtimes like Bun that scan parent directories.
403+
# Example: ["/Users", "~"] allows listing these directories' contents
404+
# without reading files or subdirectories within them.
405+
allow_list_dirs = []
406+
365407
[shell]
366408
# Additional environment variables to pass through
367409
pass_env = []

src/config/merge.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ fn merge_filesystem(global: &FilesystemConfig, project: &FilesystemConfig) -> Fi
5555
allow_read: merge_unique_strings(&global.allow_read, &project.allow_read),
5656
deny_read: merge_unique_strings(&global.deny_read, &project.deny_read),
5757
allow_write: merge_unique_strings(&global.allow_write, &project.allow_write),
58+
allow_list_dirs: merge_unique_strings(&global.allow_list_dirs, &project.allow_list_dirs),
5859
}
5960
}
6061

src/config/profile.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ pub struct ProfileFilesystem {
7373
pub allow_read: Vec<String>,
7474
pub deny_read: Vec<String>,
7575
pub allow_write: Vec<String>,
76+
/// Paths to allow directory listing only (readdir), not file contents
77+
pub allow_list_dirs: Vec<String>,
7678
}
7779

7880
/// Profile shell configuration
@@ -99,6 +101,7 @@ pub enum BuiltinProfile {
99101
Rust,
100102
Claude,
101103
Gpg,
104+
Bun,
102105
}
103106

104107
impl BuiltinProfile {
@@ -111,6 +114,7 @@ impl BuiltinProfile {
111114
"rust" => Some(Self::Rust),
112115
"claude" => Some(Self::Claude),
113116
"gpg" => Some(Self::Gpg),
117+
"bun" => Some(Self::Bun),
114118
_ => None,
115119
}
116120
}
@@ -124,6 +128,7 @@ impl BuiltinProfile {
124128
Self::Rust => "rust",
125129
Self::Claude => "claude",
126130
Self::Gpg => "gpg",
131+
Self::Bun => "bun",
127132
}
128133
}
129134

@@ -140,6 +145,7 @@ impl BuiltinProfile {
140145
Self::Rust => include_str!("../../profiles/rust.toml"),
141146
Self::Claude => include_str!("../../profiles/claude.toml"),
142147
Self::Gpg => include_str!("../../profiles/gpg.toml"),
148+
Self::Bun => include_str!("../../profiles/bun.toml"),
143149
};
144150
toml::from_str(toml_str).map_err(|e| ProfileError::InvalidBuiltin {
145151
name: self.name(),
@@ -257,6 +263,10 @@ pub fn compose_profiles(profiles: &[Profile]) -> Profile {
257263
&mut result.filesystem.allow_write,
258264
&profile.filesystem.allow_write,
259265
);
266+
merge_unique(
267+
&mut result.filesystem.allow_list_dirs,
268+
&profile.filesystem.allow_list_dirs,
269+
);
260270

261271
// Shell: merge unique env vars
262272
merge_unique(&mut result.shell.pass_env, &profile.shell.pass_env);

src/config/schema.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ pub struct FilesystemConfig {
7474
pub deny_read: Vec<String>,
7575
/// Paths to always allow writing (beyond project dir)
7676
pub allow_write: Vec<String>,
77+
/// Paths to allow directory listing only (readdir), not file contents.
78+
/// Uses Seatbelt `literal` filter - only the exact directory is listable,
79+
/// not its children. Useful for runtimes like Bun that need to scan
80+
/// parent directories during module resolution.
81+
pub allow_list_dirs: Vec<String>,
7782
}
7883

7984
/// Shell environment configuration

src/detection/project_type.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub enum ProjectType {
1010
Python,
1111
Rust,
1212
Go,
13+
Bun,
1314
}
1415

1516
impl ProjectType {
@@ -20,6 +21,7 @@ impl ProjectType {
2021
ProjectType::Python => "python",
2122
ProjectType::Rust => "rust",
2223
ProjectType::Go => "go",
24+
ProjectType::Bun => "bun",
2325
}
2426
}
2527

@@ -31,6 +33,7 @@ impl ProjectType {
3133
/// Get all known project types
3234
fn all() -> &'static [ProjectType] {
3335
&[
36+
ProjectType::Bun, // Check Bun first (more specific than Node)
3437
ProjectType::Node,
3538
ProjectType::Python,
3639
ProjectType::Rust,
@@ -45,6 +48,7 @@ impl ProjectType {
4548
ProjectType::Python => &["requirements.txt", "pyproject.toml", "setup.py"],
4649
ProjectType::Rust => &["Cargo.toml"],
4750
ProjectType::Go => &["go.mod"],
51+
ProjectType::Bun => &["bun.lockb", "bunfig.toml"],
4852
}
4953
}
5054
}
@@ -86,6 +90,7 @@ mod tests {
8690
assert_eq!(ProjectType::Python.as_str(), "python");
8791
assert_eq!(ProjectType::Rust.as_str(), "rust");
8892
assert_eq!(ProjectType::Go.as_str(), "go");
93+
assert_eq!(ProjectType::Bun.as_str(), "bun");
8994
}
9095

9196
#[test]
@@ -95,5 +100,7 @@ mod tests {
95100
assert!(ProjectType::Python.markers().contains(&"pyproject.toml"));
96101
assert!(ProjectType::Rust.markers().contains(&"Cargo.toml"));
97102
assert!(ProjectType::Go.markers().contains(&"go.mod"));
103+
assert!(ProjectType::Bun.markers().contains(&"bun.lockb"));
104+
assert!(ProjectType::Bun.markers().contains(&"bunfig.toml"));
98105
}
99106
}

src/sandbox/seatbelt.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ pub struct SandboxParams {
9898
pub deny_read: Vec<PathBuf>,
9999
/// Paths to allow writing (restricted by default)
100100
pub allow_write: Vec<PathBuf>,
101+
/// Paths to allow directory listing only (readdir), not file contents.
102+
/// Uses Seatbelt `literal` filter - allows listing a directory's entries
103+
/// without granting access to files or subdirectories within it.
104+
pub allow_list_dirs: Vec<PathBuf>,
101105
/// Raw seatbelt rules to include verbatim
102106
pub raw_rules: Option<String>,
103107
}
@@ -158,6 +162,22 @@ pub fn generate_seatbelt_profile(params: &SandboxParams) -> Result<String, Seatb
158162
}
159163
profile.push('\n');
160164

165+
// Directory listing only (readdir) - uses literal filter
166+
// Allows listing directory contents without reading files or subdirectories.
167+
// Useful for runtimes like Bun that scan parent directories during module resolution.
168+
if !params.allow_list_dirs.is_empty() {
169+
profile.push_str("; Directory listing only (readdir without file access)\n");
170+
for path in &params.allow_list_dirs {
171+
let p = path.display().to_string();
172+
let validated = validate_seatbelt_path(&p)?;
173+
// Use literal filter - only matches the exact path, not children
174+
profile.push_str(&format!(
175+
"(allow file-read-data (literal \"{validated}\"))\n"
176+
));
177+
}
178+
profile.push('\n');
179+
}
180+
161181
// Deny sensitive paths (overrides allow_read for nested sensitive paths)
162182
// Uses last-match-wins: deny after allow takes precedence
163183
if !params.deny_read.is_empty() {
@@ -532,4 +552,71 @@ mod tests {
532552
// seatbelt doesn't accept IP addresses, only "localhost" or "*"
533553
assert!(!profile.contains("127.0.0.1"));
534554
}
555+
556+
// === Directory Listing (allow_list_dirs) Tests ===
557+
558+
#[test]
559+
fn test_allow_list_dirs_uses_literal() {
560+
let params = SandboxParams {
561+
allow_list_dirs: vec![PathBuf::from("/Users")],
562+
..Default::default()
563+
};
564+
let profile = generate_seatbelt_profile(&params).unwrap();
565+
// Should use literal filter (exact match only), not subpath
566+
assert!(
567+
profile.contains(r#"(allow file-read-data (literal "/Users"))"#),
568+
"allow_list_dirs should use literal filter, got:\n{}",
569+
profile
570+
);
571+
// Should NOT use subpath (which would allow reading all contents)
572+
assert!(
573+
!profile.contains(r#"(allow file-read* (subpath "/Users"))"#),
574+
"allow_list_dirs should NOT use subpath filter"
575+
);
576+
}
577+
578+
#[test]
579+
fn test_allow_list_dirs_multiple_paths() {
580+
let params = SandboxParams {
581+
allow_list_dirs: vec![PathBuf::from("/Users"), PathBuf::from("/Users/testuser")],
582+
..Default::default()
583+
};
584+
let profile = generate_seatbelt_profile(&params).unwrap();
585+
assert!(profile.contains(r#"(allow file-read-data (literal "/Users"))"#));
586+
assert!(profile.contains(r#"(allow file-read-data (literal "/Users/testuser"))"#));
587+
}
588+
589+
#[test]
590+
fn test_allow_list_dirs_with_deny_read() {
591+
// Verify deny_read still takes precedence over allow_list_dirs
592+
let params = SandboxParams {
593+
allow_list_dirs: vec![PathBuf::from("/Users/testuser")],
594+
deny_read: vec![PathBuf::from("/Users/testuser/secret")],
595+
..Default::default()
596+
};
597+
let profile = generate_seatbelt_profile(&params).unwrap();
598+
599+
let list_pos = profile
600+
.find(r#"(allow file-read-data (literal "/Users/testuser"))"#)
601+
.expect("allow_list_dirs rule should exist");
602+
let deny_pos = profile
603+
.find(r#"(deny file-read* (subpath "/Users/testuser/secret"))"#)
604+
.expect("deny rule should exist");
605+
606+
// deny_read comes after allow_list_dirs (last-match-wins)
607+
assert!(
608+
deny_pos > list_pos,
609+
"deny rules must come after allow_list_dirs for Seatbelt last-match-wins semantics"
610+
);
611+
}
612+
613+
#[test]
614+
fn test_allow_list_dirs_section_comment() {
615+
let params = SandboxParams {
616+
allow_list_dirs: vec![PathBuf::from("/Users")],
617+
..Default::default()
618+
};
619+
let profile = generate_seatbelt_profile(&params).unwrap();
620+
assert!(profile.contains("; Directory listing only"));
621+
}
535622
}

src/shell/integration.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ alias sxo='sx online'
112112
alias sxl='sx localhost'
113113
alias sxr='sx online rust'
114114
alias sxc='sx online claude'
115+
alias sxb='sx online bun'
115116
"#;
116117

117118
const BASH_INTEGRATION: &str = r#"# sx.bash - Bash integration for sandbox CLI
@@ -159,6 +160,7 @@ alias sxo='sx online'
159160
alias sxl='sx localhost'
160161
alias sxr='sx online rust'
161162
alias sxc='sx online claude'
163+
alias sxb='sx online bun'
162164
"#;
163165

164166
const FISH_INTEGRATION: &str = r#"# sx.fish - Fish integration for sandbox CLI
@@ -216,6 +218,7 @@ alias sxo 'sx online'
216218
alias sxl 'sx localhost'
217219
alias sxr 'sx online rust'
218220
alias sxc 'sx online claude'
221+
alias sxb 'sx online bun'
219222
"#;
220223

221224
#[cfg(test)]

tests/integration.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ fn fs_sandbox_params(working_dir: PathBuf) -> SandboxParams {
8181
],
8282
deny_read: vec![],
8383
allow_write: vec![],
84+
allow_list_dirs: vec![],
8485
raw_rules: None,
8586
}
8687
}
@@ -256,6 +257,7 @@ fn network_sandbox_params(working_dir: PathBuf, mode: NetworkMode) -> SandboxPar
256257
],
257258
deny_read: vec![],
258259
allow_write: vec![],
260+
allow_list_dirs: vec![],
259261
raw_rules: None,
260262
}
261263
}

0 commit comments

Comments
 (0)