diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 89435ee6d0..34980c9a91 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -176,9 +176,10 @@ impl Codex { let user_instructions = get_user_instructions(&config).await; - let exec_policy = load_exec_policy_for_features(&config.features, &config.codex_home) - .await - .map_err(|err| CodexErr::Fatal(format!("failed to load execpolicy: {err}")))?; + let exec_policy = + load_exec_policy_for_features(&config.features, &config.codex_home, &config.cwd) + .await + .map_err(|err| CodexErr::Fatal(format!("failed to load execpolicy: {err}")))?; let exec_policy = Arc::new(RwLock::new(exec_policy)); let config = Arc::new(config); diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 2d1c2efe5e..e18b2a8dcf 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; @@ -15,6 +16,7 @@ use codex_execpolicy::blocking_append_allow_prefix_rule; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use futures::future::try_join_all; use thiserror::Error; use tokio::fs; use tokio::sync::RwLock; @@ -23,6 +25,7 @@ use tokio::task::spawn_blocking; use crate::bash::parse_shell_lc_plain_commands; use crate::features::Feature; use crate::features::Features; +use crate::git_info::resolve_root_git_project_for_trust; use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ExecApprovalRequirement; @@ -76,17 +79,33 @@ pub enum ExecPolicyUpdateError { pub(crate) async fn load_exec_policy_for_features( features: &Features, codex_home: &Path, + cwd: &Path, ) -> Result { if !features.enabled(Feature::ExecPolicy) { Ok(Policy::empty()) } else { - load_exec_policy(codex_home).await + load_exec_policy(codex_home, Some(cwd)).await } } -pub async fn load_exec_policy(codex_home: &Path) -> Result { - let policy_dir = codex_home.join(POLICY_DIR_NAME); - let policy_paths = collect_policy_files(&policy_dir).await?; +pub async fn load_exec_policy( + codex_home: &Path, + cwd: Option<&Path>, +) -> Result { + let cwd = cwd + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()); + + let policy_dirs = execpolicy_directories(codex_home, cwd.as_deref()); + let policy_paths = try_join_all( + policy_dirs + .iter() + .map(|policy_dir| collect_policy_files(policy_dir.as_path())), + ) + .await? + .into_iter() + .flatten() + .collect::>(); let mut parser = PolicyParser::new(); for policy_path in &policy_paths { @@ -108,9 +127,10 @@ pub async fn load_exec_policy(codex_home: &Path) -> Result) -> Vec { + let mut policy_dirs = HashSet::new(); + + // ~/.codex/policy + policy_dirs.insert(codex_home.join(POLICY_DIR_NAME)); + + // /.codex/policy + if let Some(repo_root) = cwd.and_then(resolve_root_git_project_for_trust) { + policy_dirs.insert(repo_root.join(".codex").join(POLICY_DIR_NAME)); + } + + // /etc/codex/policy + policy_dirs.insert(PathBuf::from("/etc/codex").join(POLICY_DIR_NAME)); + + policy_dirs.into_iter().collect() +} + async fn collect_policy_files(dir: &Path) -> Result, ExecPolicyError> { let mut read_dir = match fs::read_dir(dir).await { Ok(read_dir) => read_dir, @@ -301,6 +338,8 @@ mod tests { use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::fs; + use std::path::PathBuf; + use std::process::Command; use std::sync::Arc; use tempfile::tempdir; @@ -310,7 +349,7 @@ mod tests { features.disable(Feature::ExecPolicy); let temp_dir = tempdir().expect("create temp dir"); - let policy = load_exec_policy_for_features(&features, temp_dir.path()) + let policy = load_exec_policy_for_features(&features, temp_dir.path(), temp_dir.path()) .await .expect("policy result"); @@ -340,6 +379,16 @@ mod tests { assert!(files.is_empty()); } + #[test] + fn execpolicy_directories_include_system_policy_dir() { + let codex_home = PathBuf::from("/tmp/codex-home"); + + let dirs = execpolicy_directories(&codex_home, None); + + assert!(dirs.contains(&codex_home.join(POLICY_DIR_NAME))); + assert!(dirs.contains(&PathBuf::from("/etc/codex").join(POLICY_DIR_NAME))); + } + #[tokio::test] async fn loads_policies_from_policy_subdirectory() { let temp_dir = tempdir().expect("create temp dir"); @@ -351,7 +400,7 @@ mod tests { ) .expect("write policy file"); - let policy = load_exec_policy(temp_dir.path()) + let policy = load_exec_policy(temp_dir.path(), Some(temp_dir.path())) .await .expect("policy result"); let command = [vec!["rm".to_string()]]; @@ -376,7 +425,7 @@ mod tests { ) .expect("write policy file"); - let policy = load_exec_policy(temp_dir.path()) + let policy = load_exec_policy(temp_dir.path(), Some(temp_dir.path())) .await .expect("policy result"); let command = [vec!["ls".to_string()]]; @@ -392,6 +441,54 @@ mod tests { ); } + #[tokio::test] + async fn loads_policies_from_git_repo_codex_dir() { + let temp_dir = tempdir().expect("create temp dir"); + let codex_home = temp_dir.path().join("home"); + let repo_root = temp_dir.path().join("repo"); + fs::create_dir_all(&repo_root).expect("create repo dir"); + let git_init_status = Command::new("git") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .arg("init") + .current_dir(&repo_root) + .status() + .expect("initialize git repo"); + assert!( + git_init_status.success(), + "git init failed: {git_init_status:?}" + ); + + let nested_cwd = repo_root.join("nested"); + fs::create_dir_all(&nested_cwd).expect("create nested cwd"); + + let repo_policy_dir = repo_root.join(".codex").join(POLICY_DIR_NAME); + fs::create_dir_all(&repo_policy_dir).expect("create repo policy dir"); + fs::write( + repo_policy_dir.join("deny.codexpolicy"), + r#"prefix_rule(pattern=["git-policy"], decision="forbidden")"#, + ) + .expect("write repo policy file"); + + let policy = load_exec_policy(&codex_home, Some(nested_cwd.as_path())) + .await + .expect("policy result"); + let command = [vec!["git-policy".to_string()]]; + let evaluation = policy.check_multiple(command.iter(), &|_| Decision::Allow); + + assert_eq!(evaluation.decision, Decision::Forbidden); + assert!( + evaluation.matched_rules.iter().any(|rule_match| matches!( + rule_match, + RuleMatch::PrefixRuleMatch { + matched_prefix, + decision: Decision::Forbidden + } if matched_prefix == &vec!["git-policy".to_string()] + )), + "expected git repo execpolicy rule to match: {evaluation:?}" + ); + } + #[tokio::test] async fn evaluates_bash_lc_inner_commands() { let policy_src = r#" diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/posix.rs index 16da5885f5..cdf32797c5 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/posix.rs @@ -221,7 +221,9 @@ fn format_program_name(path: &Path, preserve_program_paths: bool) -> Option anyhow::Result { let codex_home = find_codex_home().context("failed to resolve codex_home for execpolicy")?; - codex_core::load_exec_policy(&codex_home) + let cwd = std::env::current_dir().ok(); + + codex_core::load_exec_policy(&codex_home, cwd.as_deref()) .await .map_err(anyhow::Error::from) }