Skip to content

Commit 752b115

Browse files
committed
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
1 parent 6864fe8 commit 752b115

8 files changed

Lines changed: 199 additions & 0 deletions

File tree

profiles/bun.toml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
network_mode = "online"
16+
17+
[filesystem]
18+
# Bun installation and cache
19+
allow_read = [
20+
"~/.bun",
21+
]
22+
23+
# Allow listing parent directories for Bun's module resolution
24+
# Uses Seatbelt 'literal' filter - only the exact directory is listable
25+
allow_list_dirs = [
26+
"/Users",
27+
"~",
28+
]
29+
30+
# Deny access to sensitive directories even if parent listing is allowed
31+
# Add your sensitive paths here
32+
deny_read = [
33+
"~/.ssh",
34+
"~/.gnupg",
35+
"~/.aws",
36+
"~/.config/gh",
37+
]
38+
39+
[shell]
40+
pass_env = [
41+
"BUN_INSTALL",
42+
"NODE_ENV",
43+
"npm_config_registry",
44+
]

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: 88 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,20 @@ 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!("(allow file-read-data (literal \"{validated}\"))\n"));
175+
}
176+
profile.push('\n');
177+
}
178+
161179
// Deny sensitive paths (overrides allow_read for nested sensitive paths)
162180
// Uses last-match-wins: deny after allow takes precedence
163181
if !params.deny_read.is_empty() {
@@ -532,4 +550,74 @@ mod tests {
532550
// seatbelt doesn't accept IP addresses, only "localhost" or "*"
533551
assert!(!profile.contains("127.0.0.1"));
534552
}
553+
554+
// === Directory Listing (allow_list_dirs) Tests ===
555+
556+
#[test]
557+
fn test_allow_list_dirs_uses_literal() {
558+
let params = SandboxParams {
559+
allow_list_dirs: vec![PathBuf::from("/Users")],
560+
..Default::default()
561+
};
562+
let profile = generate_seatbelt_profile(&params).unwrap();
563+
// Should use literal filter (exact match only), not subpath
564+
assert!(
565+
profile.contains(r#"(allow file-read-data (literal "/Users"))"#),
566+
"allow_list_dirs should use literal filter, got:\n{}",
567+
profile
568+
);
569+
// Should NOT use subpath (which would allow reading all contents)
570+
assert!(
571+
!profile.contains(r#"(allow file-read* (subpath "/Users"))"#),
572+
"allow_list_dirs should NOT use subpath filter"
573+
);
574+
}
575+
576+
#[test]
577+
fn test_allow_list_dirs_multiple_paths() {
578+
let params = SandboxParams {
579+
allow_list_dirs: vec![
580+
PathBuf::from("/Users"),
581+
PathBuf::from("/Users/testuser"),
582+
],
583+
..Default::default()
584+
};
585+
let profile = generate_seatbelt_profile(&params).unwrap();
586+
assert!(profile.contains(r#"(allow file-read-data (literal "/Users"))"#));
587+
assert!(profile.contains(r#"(allow file-read-data (literal "/Users/testuser"))"#));
588+
}
589+
590+
#[test]
591+
fn test_allow_list_dirs_with_deny_read() {
592+
// Verify deny_read still takes precedence over allow_list_dirs
593+
let params = SandboxParams {
594+
allow_list_dirs: vec![PathBuf::from("/Users/testuser")],
595+
deny_read: vec![PathBuf::from("/Users/testuser/secret")],
596+
..Default::default()
597+
};
598+
let profile = generate_seatbelt_profile(&params).unwrap();
599+
600+
let list_pos = profile
601+
.find(r#"(allow file-read-data (literal "/Users/testuser"))"#)
602+
.expect("allow_list_dirs rule should exist");
603+
let deny_pos = profile
604+
.find(r#"(deny file-read* (subpath "/Users/testuser/secret"))"#)
605+
.expect("deny rule should exist");
606+
607+
// deny_read comes after allow_list_dirs (last-match-wins)
608+
assert!(
609+
deny_pos > list_pos,
610+
"deny rules must come after allow_list_dirs for Seatbelt last-match-wins semantics"
611+
);
612+
}
613+
614+
#[test]
615+
fn test_allow_list_dirs_section_comment() {
616+
let params = SandboxParams {
617+
allow_list_dirs: vec![PathBuf::from("/Users")],
618+
..Default::default()
619+
};
620+
let profile = generate_seatbelt_profile(&params).unwrap();
621+
assert!(profile.contains("; Directory listing only"));
622+
}
535623
}

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)