@@ -23,6 +23,7 @@ use tokio::task::spawn_blocking;
2323use crate :: bash:: parse_shell_lc_plain_commands;
2424use crate :: features:: Feature ;
2525use crate :: features:: Features ;
26+ use crate :: git_info:: resolve_root_git_project_for_trust;
2627use crate :: sandboxing:: SandboxPermissions ;
2728use crate :: tools:: sandboxing:: ExecApprovalRequirement ;
2829
@@ -76,17 +77,28 @@ pub enum ExecPolicyUpdateError {
7677pub ( crate ) async fn load_exec_policy_for_features (
7778 features : & Features ,
7879 codex_home : & Path ,
80+ cwd : & Path ,
7981) -> Result < Policy , ExecPolicyError > {
8082 if !features. enabled ( Feature :: ExecPolicy ) {
8183 Ok ( Policy :: empty ( ) )
8284 } else {
83- load_exec_policy ( codex_home) . await
85+ load_exec_policy ( codex_home, Some ( cwd ) ) . await
8486 }
8587}
8688
87- pub async fn load_exec_policy ( codex_home : & Path ) -> Result < Policy , ExecPolicyError > {
88- let policy_dir = codex_home. join ( POLICY_DIR_NAME ) ;
89- let policy_paths = collect_policy_files ( & policy_dir) . await ?;
89+ pub async fn load_exec_policy (
90+ codex_home : & Path ,
91+ cwd : Option < & Path > ,
92+ ) -> Result < Policy , ExecPolicyError > {
93+ let cwd = cwd
94+ . map ( PathBuf :: from)
95+ . or_else ( || std:: env:: current_dir ( ) . ok ( ) ) ;
96+
97+ let policy_dirs = execpolicy_directories ( codex_home, cwd. as_deref ( ) ) ;
98+ let mut policy_paths = Vec :: new ( ) ;
99+ for policy_dir in & policy_dirs {
100+ policy_paths. extend ( collect_policy_files ( policy_dir) . await ?) ;
101+ }
90102
91103 let mut parser = PolicyParser :: new ( ) ;
92104 for policy_path in & policy_paths {
@@ -108,9 +120,10 @@ pub async fn load_exec_policy(codex_home: &Path) -> Result<Policy, ExecPolicyErr
108120
109121 let policy = parser. build ( ) ;
110122 tracing:: debug!(
111- "loaded execpolicy from {} files in {}" ,
123+ policy_dirs = ?policy_dirs,
124+ "loaded execpolicy from {} files across {} policy directories" ,
112125 policy_paths. len( ) ,
113- policy_dir . display ( )
126+ policy_dirs . len ( )
114127 ) ;
115128
116129 Ok ( policy)
@@ -246,6 +259,31 @@ pub(crate) async fn create_exec_approval_requirement_for_command(
246259 }
247260}
248261
262+ fn execpolicy_directories ( codex_home : & Path , cwd : Option < & Path > ) -> Vec < PathBuf > {
263+ let mut policy_dirs = Vec :: new ( ) ;
264+ push_unique_policy_dir ( & mut policy_dirs, codex_home. join ( POLICY_DIR_NAME ) ) ;
265+
266+ if let Some ( repo_root) = cwd. and_then ( resolve_root_git_project_for_trust) {
267+ push_unique_policy_dir (
268+ & mut policy_dirs,
269+ repo_root. join ( ".codex" ) . join ( POLICY_DIR_NAME ) ,
270+ ) ;
271+ }
272+
273+ push_unique_policy_dir (
274+ & mut policy_dirs,
275+ PathBuf :: from ( "/etc/codex" ) . join ( POLICY_DIR_NAME ) ,
276+ ) ;
277+
278+ policy_dirs
279+ }
280+
281+ fn push_unique_policy_dir ( policy_dirs : & mut Vec < PathBuf > , dir : PathBuf ) {
282+ if !policy_dirs. iter ( ) . any ( |existing| existing == & dir) {
283+ policy_dirs. push ( dir) ;
284+ }
285+ }
286+
249287async fn collect_policy_files ( dir : & Path ) -> Result < Vec < PathBuf > , ExecPolicyError > {
250288 let mut read_dir = match fs:: read_dir ( dir) . await {
251289 Ok ( read_dir) => read_dir,
@@ -301,6 +339,8 @@ mod tests {
301339 use codex_protocol:: protocol:: SandboxPolicy ;
302340 use pretty_assertions:: assert_eq;
303341 use std:: fs;
342+ use std:: path:: PathBuf ;
343+ use std:: process:: Command ;
304344 use std:: sync:: Arc ;
305345 use tempfile:: tempdir;
306346
@@ -310,7 +350,7 @@ mod tests {
310350 features. disable ( Feature :: ExecPolicy ) ;
311351 let temp_dir = tempdir ( ) . expect ( "create temp dir" ) ;
312352
313- let policy = load_exec_policy_for_features ( & features, temp_dir. path ( ) )
353+ let policy = load_exec_policy_for_features ( & features, temp_dir. path ( ) , temp_dir . path ( ) )
314354 . await
315355 . expect ( "policy result" ) ;
316356
@@ -340,6 +380,16 @@ mod tests {
340380 assert ! ( files. is_empty( ) ) ;
341381 }
342382
383+ #[ test]
384+ fn execpolicy_directories_include_system_policy_dir ( ) {
385+ let codex_home = PathBuf :: from ( "/tmp/codex-home" ) ;
386+
387+ let dirs = execpolicy_directories ( & codex_home, None ) ;
388+
389+ assert ! ( dirs. contains( & codex_home. join( POLICY_DIR_NAME ) ) ) ;
390+ assert ! ( dirs. contains( & PathBuf :: from( "/etc/codex" ) . join( POLICY_DIR_NAME ) ) ) ;
391+ }
392+
343393 #[ tokio:: test]
344394 async fn loads_policies_from_policy_subdirectory ( ) {
345395 let temp_dir = tempdir ( ) . expect ( "create temp dir" ) ;
@@ -351,7 +401,7 @@ mod tests {
351401 )
352402 . expect ( "write policy file" ) ;
353403
354- let policy = load_exec_policy ( temp_dir. path ( ) )
404+ let policy = load_exec_policy ( temp_dir. path ( ) , Some ( temp_dir . path ( ) ) )
355405 . await
356406 . expect ( "policy result" ) ;
357407 let command = [ vec ! [ "rm" . to_string( ) ] ] ;
@@ -376,7 +426,7 @@ mod tests {
376426 )
377427 . expect ( "write policy file" ) ;
378428
379- let policy = load_exec_policy ( temp_dir. path ( ) )
429+ let policy = load_exec_policy ( temp_dir. path ( ) , Some ( temp_dir . path ( ) ) )
380430 . await
381431 . expect ( "policy result" ) ;
382432 let command = [ vec ! [ "ls" . to_string( ) ] ] ;
@@ -392,6 +442,54 @@ mod tests {
392442 ) ;
393443 }
394444
445+ #[ tokio:: test]
446+ async fn loads_policies_from_git_repo_codex_dir ( ) {
447+ let temp_dir = tempdir ( ) . expect ( "create temp dir" ) ;
448+ let codex_home = temp_dir. path ( ) . join ( "home" ) ;
449+ let repo_root = temp_dir. path ( ) . join ( "repo" ) ;
450+ fs:: create_dir_all ( & repo_root) . expect ( "create repo dir" ) ;
451+ let git_init_status = Command :: new ( "git" )
452+ . env ( "GIT_CONFIG_GLOBAL" , "/dev/null" )
453+ . env ( "GIT_CONFIG_NOSYSTEM" , "1" )
454+ . arg ( "init" )
455+ . current_dir ( & repo_root)
456+ . status ( )
457+ . expect ( "initialize git repo" ) ;
458+ assert ! (
459+ git_init_status. success( ) ,
460+ "git init failed: {git_init_status:?}"
461+ ) ;
462+
463+ let nested_cwd = repo_root. join ( "nested" ) ;
464+ fs:: create_dir_all ( & nested_cwd) . expect ( "create nested cwd" ) ;
465+
466+ let repo_policy_dir = repo_root. join ( ".codex" ) . join ( POLICY_DIR_NAME ) ;
467+ fs:: create_dir_all ( & repo_policy_dir) . expect ( "create repo policy dir" ) ;
468+ fs:: write (
469+ repo_policy_dir. join ( "deny.codexpolicy" ) ,
470+ r#"prefix_rule(pattern=["git-policy"], decision="forbidden")"# ,
471+ )
472+ . expect ( "write repo policy file" ) ;
473+
474+ let policy = load_exec_policy ( & codex_home, Some ( nested_cwd. as_path ( ) ) )
475+ . await
476+ . expect ( "policy result" ) ;
477+ let command = [ vec ! [ "git-policy" . to_string( ) ] ] ;
478+ let evaluation = policy. check_multiple ( command. iter ( ) , & |_| Decision :: Allow ) ;
479+
480+ assert_eq ! ( evaluation. decision, Decision :: Forbidden ) ;
481+ assert ! (
482+ evaluation. matched_rules. iter( ) . any( |rule_match| matches!(
483+ rule_match,
484+ RuleMatch :: PrefixRuleMatch {
485+ matched_prefix,
486+ decision: Decision :: Forbidden
487+ } if matched_prefix == & vec![ "git-policy" . to_string( ) ]
488+ ) ) ,
489+ "expected git repo execpolicy rule to match: {evaluation:?}"
490+ ) ;
491+ }
492+
395493 #[ tokio:: test]
396494 async fn evaluates_bash_lc_inner_commands ( ) {
397495 let policy_src = r#"
0 commit comments