@@ -26,6 +26,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry;
2626use codex_protocol:: permissions:: FileSystemSandboxPolicy ;
2727use codex_protocol:: permissions:: FileSystemSpecialPath ;
2828use codex_protocol:: permissions:: NetworkSandboxPolicy ;
29+ use codex_protocol:: permissions:: PRESERVED_PATH_NAMES ;
2930use codex_protocol:: protocol:: SandboxPolicy ;
3031use codex_utils_absolute_path:: AbsolutePathBuf ;
3132use pretty_assertions:: assert_eq;
@@ -59,6 +60,26 @@ fn seatbelt_policy_arg(args: &[String]) -> &str {
5960 . expect ( "seatbelt args should include policy text" )
6061}
6162
63+ fn seatbelt_preserved_path_name_requirements ( root : & Path ) -> String {
64+ let mut root = root. to_string_lossy ( ) . to_string ( ) ;
65+ while root. len ( ) > 1 && root. ends_with ( '/' ) {
66+ root. pop ( ) ;
67+ }
68+ let root = regex_lite:: escape ( & root) ;
69+ PRESERVED_PATH_NAMES
70+ . iter ( )
71+ . map ( |name| {
72+ let name = regex_lite:: escape ( name) ;
73+ if root == "/" {
74+ format ! ( r#"(require-not (regex #"^/(.*/)?{name}(/.*)?$"))"# )
75+ } else {
76+ format ! ( r#"(require-not (regex #"^{root}/(.*/)?{name}(/.*)?$"))"# )
77+ }
78+ } )
79+ . collect :: < Vec < _ > > ( )
80+ . join ( " " )
81+ }
82+
6283struct TestConfigReloader ;
6384
6485#[ async_trait:: async_trait]
@@ -200,8 +221,10 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access()
200221 writable_definitions,
201222 vec![
202223 "-DWRITABLE_ROOT_0=/" . to_string( ) ,
203- "-DWRITABLE_ROOT_0_EXCLUDED_0=/.codex" . to_string( ) ,
204- format!( "-DWRITABLE_ROOT_0_EXCLUDED_1={}" , unreadable_root. display( ) ) ,
224+ "-DWRITABLE_ROOT_0_EXCLUDED_0=/.git" . to_string( ) ,
225+ "-DWRITABLE_ROOT_0_EXCLUDED_1=/.agents" . to_string( ) ,
226+ "-DWRITABLE_ROOT_0_EXCLUDED_2=/.codex" . to_string( ) ,
227+ format!( "-DWRITABLE_ROOT_0_EXCLUDED_3={}" , unreadable_root. display( ) ) ,
205228 ] ,
206229 "unexpected write carveout parameters in args: {args:#?}"
207230 ) ;
@@ -773,12 +796,13 @@ fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() {
773796#[ test]
774797fn create_seatbelt_args_with_read_only_git_and_codex_subpaths ( ) {
775798 // Create a temporary workspace with two writable roots: one containing
776- // top-level .git and .codex directories and one without them.
799+ // top-level preserved paths and one without them.
777800 let tmp = TempDir :: new ( ) . expect ( "tempdir" ) ;
778801 let PopulatedTmp {
779802 vulnerable_root,
780803 vulnerable_root_canonical,
781804 dot_git_canonical,
805+ dot_agents_canonical : _,
782806 dot_codex_canonical,
783807 empty_root,
784808 empty_root_canonical,
@@ -828,12 +852,20 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
828852 ) ;
829853 assert ! (
830854 policy_text. contains( "WRITABLE_ROOT_0_EXCLUDED_0" ) ,
855+ "expected cwd .git carveout in policy:\n {policy_text}" ,
856+ ) ;
857+ assert ! (
858+ policy_text. contains( "WRITABLE_ROOT_0_EXCLUDED_1" ) ,
859+ "expected cwd .agents carveout in policy:\n {policy_text}" ,
860+ ) ;
861+ assert ! (
862+ policy_text. contains( "WRITABLE_ROOT_0_EXCLUDED_2" ) ,
831863 "expected cwd .codex carveout in policy:\n {policy_text}" ,
832864 ) ;
833865 assert ! (
834866 policy_text. contains( "WRITABLE_ROOT_1_EXCLUDED_0" )
835867 && policy_text. contains( "WRITABLE_ROOT_1_EXCLUDED_1" ) ,
836- "expected explicit writable root .git/.codex carveouts in policy:\n {policy_text}" ,
868+ "expected explicit writable root preserved path carveouts in policy:\n {policy_text}" ,
837869 ) ;
838870 assert ! (
839871 policy_text. contains( "(subpath (param \" WRITABLE_ROOT_2\" ))" ) ,
@@ -849,6 +881,20 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
849881 ) ,
850882 format ! (
851883 "-DWRITABLE_ROOT_0_EXCLUDED_0={}" ,
884+ cwd. canonicalize( )
885+ . expect( "canonicalize cwd" )
886+ . join( ".git" )
887+ . display( )
888+ ) ,
889+ format ! (
890+ "-DWRITABLE_ROOT_0_EXCLUDED_1={}" ,
891+ cwd. canonicalize( )
892+ . expect( "canonicalize cwd" )
893+ . join( ".agents" )
894+ . display( )
895+ ) ,
896+ format ! (
897+ "-DWRITABLE_ROOT_0_EXCLUDED_2={}" ,
852898 cwd. canonicalize( )
853899 . expect( "canonicalize cwd" )
854900 . join( ".codex" )
@@ -864,12 +910,28 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
864910 ) ,
865911 format ! (
866912 "-DWRITABLE_ROOT_1_EXCLUDED_1={}" ,
913+ vulnerable_root_canonical. join( ".agents" ) . to_string_lossy( )
914+ ) ,
915+ format ! (
916+ "-DWRITABLE_ROOT_1_EXCLUDED_2={}" ,
867917 dot_codex_canonical. to_string_lossy( )
868918 ) ,
869919 format ! (
870920 "-DWRITABLE_ROOT_2={}" ,
871921 empty_root_canonical. to_string_lossy( )
872922 ) ,
923+ format ! (
924+ "-DWRITABLE_ROOT_2_EXCLUDED_0={}" ,
925+ empty_root_canonical. join( ".git" ) . to_string_lossy( )
926+ ) ,
927+ format ! (
928+ "-DWRITABLE_ROOT_2_EXCLUDED_1={}" ,
929+ empty_root_canonical. join( ".agents" ) . to_string_lossy( )
930+ ) ,
931+ format ! (
932+ "-DWRITABLE_ROOT_2_EXCLUDED_2={}" ,
933+ empty_root_canonical. join( ".codex" ) . to_string_lossy( )
934+ ) ,
873935 ] ;
874936 let writable_definitions: Vec < String > = args
875937 . iter ( )
@@ -1037,11 +1099,11 @@ fn create_seatbelt_args_block_first_time_dot_codex_creation_with_exact_and_desce
10371099
10381100 let policy_text = seatbelt_policy_arg ( & args) ;
10391101 assert ! (
1040- policy_text. contains( "(require-not (literal (param \" WRITABLE_ROOT_0_EXCLUDED_1 \" )))" ) ,
1102+ policy_text. contains( "(require-not (literal (param \" WRITABLE_ROOT_0_EXCLUDED_2 \" )))" ) ,
10411103 "expected exact .codex carveout in policy:\n {policy_text}"
10421104 ) ;
10431105 assert ! (
1044- policy_text. contains( "(require-not (subpath (param \" WRITABLE_ROOT_0_EXCLUDED_1 \" )))" ) ,
1106+ policy_text. contains( "(require-not (subpath (param \" WRITABLE_ROOT_0_EXCLUDED_2 \" )))" ) ,
10451107 "expected descendant .codex carveout in policy:\n {policy_text}"
10461108 ) ;
10471109}
@@ -1146,19 +1208,20 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() {
11461208#[ test]
11471209fn create_seatbelt_args_for_cwd_as_git_repo ( ) {
11481210 // Create a temporary workspace with two writable roots: one containing
1149- // top-level .git and .codex directories and one without them.
1211+ // top-level preserved paths and one without them.
11501212 let tmp = TempDir :: new ( ) . expect ( "tempdir" ) ;
11511213 let PopulatedTmp {
11521214 vulnerable_root,
11531215 vulnerable_root_canonical,
11541216 dot_git_canonical,
1217+ dot_agents_canonical,
11551218 dot_codex_canonical,
11561219 ..
11571220 } = populate_tmpdir ( tmp. path ( ) ) ;
11581221
11591222 // Build a policy that does not specify any writable_roots, but does
1160- // use the default ones (cwd and TMPDIR) and verifies the `.git` and
1161- // `.codex` checks are done properly for cwd.
1223+ // use the default ones (cwd and TMPDIR) and verifies the preserved
1224+ // path checks are done properly for cwd.
11621225 let policy = SandboxPolicy :: WorkspaceWrite {
11631226 writable_roots : vec ! [ ] ,
11641227 network_access : false ,
@@ -1193,23 +1256,33 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
11931256 . and_then ( |p| p. canonicalize ( ) . ok ( ) )
11941257 . map ( |p| p. to_string_lossy ( ) . to_string ( ) ) ;
11951258
1196- let tempdir_policy_entry = if tmpdir_env_var. is_some ( ) {
1197- r#" (require-all (subpath (param "WRITABLE_ROOT_2")) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_1"))) )"#
1259+ let slash_tmp = PathBuf :: from ( "/tmp" )
1260+ . canonicalize ( )
1261+ . expect ( "canonicalize /tmp" ) ;
1262+ let tempdir_policy_entry = if let Some ( p) = & tmpdir_env_var {
1263+ let preserved_requirements = seatbelt_preserved_path_name_requirements ( Path :: new ( p) ) ;
1264+ format ! (
1265+ r#" (require-all (subpath (param "WRITABLE_ROOT_2")) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_1"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_2"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_2"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_3"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_3"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_4"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_4"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_5"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_5"))) {preserved_requirements} )"#
1266+ )
11981267 } else {
1199- ""
1268+ String :: new ( )
12001269 } ;
1270+ let root_0_preserved_requirements =
1271+ seatbelt_preserved_path_name_requirements ( & vulnerable_root_canonical) ;
1272+ let root_1_preserved_requirements = seatbelt_preserved_path_name_requirements ( & slash_tmp) ;
12011273
12021274 // Build the expected policy text using a raw string for readability.
12031275 // Note that the policy includes:
12041276 // - the base policy,
12051277 // - read-only access to the filesystem,
1206- // - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2.
1278+ // - write access to WRITABLE_ROOT_0, WRITABLE_ROOT_1, and cwd as
1279+ // WRITABLE_ROOT_2, each with preserved path carveouts.
12071280 let expected_policy = format ! (
12081281 r#"{MACOS_SEATBELT_BASE_POLICY}
12091282; allow read-only file operations
12101283(allow file-read*)
12111284(allow file-write*
1212- (require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_1"))) ) ( subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry}
1285+ (require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_2"))) (require-not ( subpath (param "WRITABLE_ROOT_0_EXCLUDED_2"))) {root_0_preserved_requirements} ) (require-all (subpath (param " WRITABLE_ROOT_1")) (require-not (literal (param "WRITABLE_ROOT_1_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_1_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_1_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_1_EXCLUDED_1"))) (require-not (literal (param "WRITABLE_ROOT_1_EXCLUDED_2"))) (require-not (subpath (param "WRITABLE_ROOT_1_EXCLUDED_2"))) {root_1_preserved_requirements} ){tempdir_policy_entry}
12131286)
12141287
12151288"# ,
@@ -1228,25 +1301,42 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
12281301 ) ,
12291302 format!(
12301303 "-DWRITABLE_ROOT_0_EXCLUDED_1={}" ,
1304+ dot_agents_canonical. to_string_lossy( )
1305+ ) ,
1306+ format!(
1307+ "-DWRITABLE_ROOT_0_EXCLUDED_2={}" ,
12311308 dot_codex_canonical. to_string_lossy( )
12321309 ) ,
1310+ format!( "-DWRITABLE_ROOT_1={}" , slash_tmp. to_string_lossy( ) ) ,
12331311 format!(
1234- "-DWRITABLE_ROOT_1={}" ,
1235- PathBuf :: from( "/tmp" )
1236- . canonicalize( )
1237- . expect( "canonicalize /tmp" )
1238- . to_string_lossy( )
1312+ "-DWRITABLE_ROOT_1_EXCLUDED_0={}" ,
1313+ slash_tmp. join( ".git" ) . to_string_lossy( )
1314+ ) ,
1315+ format!(
1316+ "-DWRITABLE_ROOT_1_EXCLUDED_1={}" ,
1317+ slash_tmp. join( ".agents" ) . to_string_lossy( )
1318+ ) ,
1319+ format!(
1320+ "-DWRITABLE_ROOT_1_EXCLUDED_2={}" ,
1321+ slash_tmp. join( ".codex" ) . to_string_lossy( )
12391322 ) ,
12401323 ] ;
12411324
12421325 if let Some ( p) = tmpdir_env_var {
12431326 expected_args. push ( format ! ( "-DWRITABLE_ROOT_2={p}" ) ) ;
1327+ expected_args. push ( format ! ( "-DWRITABLE_ROOT_2_EXCLUDED_0={p}/.git" ) ) ;
1328+ expected_args. push ( format ! ( "-DWRITABLE_ROOT_2_EXCLUDED_1={p}/.agents" ) ) ;
1329+ expected_args. push ( format ! ( "-DWRITABLE_ROOT_2_EXCLUDED_2={p}/.codex" ) ) ;
12441330 expected_args. push ( format ! (
1245- "-DWRITABLE_ROOT_2_EXCLUDED_0 ={}" ,
1331+ "-DWRITABLE_ROOT_2_EXCLUDED_3 ={}" ,
12461332 dot_git_canonical. to_string_lossy( )
12471333 ) ) ;
12481334 expected_args. push ( format ! (
1249- "-DWRITABLE_ROOT_2_EXCLUDED_1={}" ,
1335+ "-DWRITABLE_ROOT_2_EXCLUDED_4={}" ,
1336+ dot_agents_canonical. to_string_lossy( )
1337+ ) ) ;
1338+ expected_args. push ( format ! (
1339+ "-DWRITABLE_ROOT_2_EXCLUDED_5={}" ,
12501340 dot_codex_canonical. to_string_lossy( )
12511341 ) ) ;
12521342 }
@@ -1264,7 +1354,7 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
12641354}
12651355
12661356struct PopulatedTmp {
1267- /// Path containing a .git and .codex subfolder .
1357+ /// Path containing preserved subfolders .
12681358 /// For the purposes of this test, we consider this a "vulnerable" root
12691359 /// because a bad actor could write to .git/hooks/pre-commit so an
12701360 /// unsuspecting user would run code as privileged the next time they
@@ -1274,9 +1364,10 @@ struct PopulatedTmp {
12741364 vulnerable_root : PathBuf ,
12751365 vulnerable_root_canonical : PathBuf ,
12761366 dot_git_canonical : PathBuf ,
1367+ dot_agents_canonical : PathBuf ,
12771368 dot_codex_canonical : PathBuf ,
12781369
1279- /// Path without .git or .codex subfolders.
1370+ /// Path without preserved subfolders.
12801371 empty_root : PathBuf ,
12811372 /// Canonicalized version of `empty_root`.
12821373 empty_root_canonical : PathBuf ,
@@ -1310,12 +1401,14 @@ fn populate_tmpdir(tmp: &Path) -> PopulatedTmp {
13101401 . canonicalize ( )
13111402 . expect ( "canonicalize vulnerable_root" ) ;
13121403 let dot_git_canonical = vulnerable_root_canonical. join ( ".git" ) ;
1404+ let dot_agents_canonical = vulnerable_root_canonical. join ( ".agents" ) ;
13131405 let dot_codex_canonical = vulnerable_root_canonical. join ( ".codex" ) ;
13141406 let empty_root_canonical = empty_root. canonicalize ( ) . expect ( "canonicalize empty_root" ) ;
13151407 PopulatedTmp {
13161408 vulnerable_root,
13171409 vulnerable_root_canonical,
13181410 dot_git_canonical,
1411+ dot_agents_canonical,
13191412 dot_codex_canonical,
13201413 empty_root,
13211414 empty_root_canonical,
0 commit comments