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