@@ -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}
0 commit comments