feat: load ExecPolicyManager from ConfigLayerStack (#8453)

https://github.com/openai/codex/pull/8354 added support for in-repo
`.config/` files, so this PR updates the logic for loading `*.rules`
files to load `*.rules` files from all relevant layers. The main change
to the business logic is `load_exec_policy()` in
`codex-rs/core/src/exec_policy.rs`.

Note this adds a `config_folder()` method to `ConfigLayerSource` that
returns `Option<AbsolutePathBuf>` so that it is straightforward to
iterate over the sources and get the associated config folder, if any.
This commit is contained in:
Michael Bolin 2025-12-22 17:24:17 -08:00 committed by GitHub
parent 14dbd0610a
commit 277babba79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 166 additions and 19 deletions

View file

@ -240,7 +240,7 @@ impl Codex {
)
.await;
let exec_policy = ExecPolicyManager::load(&config.features, &config.codex_home)
let exec_policy = ExecPolicyManager::load(&config.features, &config.config_layer_stack)
.await
.map_err(|err| CodexErr::Fatal(format!("failed to load execpolicy: {err}")))?;

View file

@ -28,6 +28,7 @@ pub use config_requirements::ConfigRequirements;
pub use merge::merge_toml_values;
pub use state::ConfigLayerEntry;
pub use state::ConfigLayerStack;
pub use state::ConfigLayerStackOrdering;
pub use state::LoaderOverrides;
/// On Unix systems, load requirements from this file path, if present.

View file

@ -50,6 +50,25 @@ impl ConfigLayerEntry {
config: serde_json::to_value(&self.config).unwrap_or(JsonValue::Null),
}
}
// Get the `.codex/` folder associated with this config layer, if any.
pub fn config_folder(&self) -> Option<AbsolutePathBuf> {
match &self.name {
ConfigLayerSource::Mdm { .. } => None,
ConfigLayerSource::System { .. } => None,
ConfigLayerSource::User { file } => file.parent(),
ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()),
ConfigLayerSource::SessionFlags => None,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => None,
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigLayerStackOrdering {
LowestPrecedenceFirst,
HighestPrecedenceFirst,
}
#[derive(Debug, Clone, Default, PartialEq)]
@ -156,7 +175,16 @@ impl ConfigLayerStack {
/// Returns the highest-precedence to lowest-precedence layers, so
/// `ConfigLayerSource::SessionFlags` would be first, if present.
pub fn layers_high_to_low(&self) -> Vec<&ConfigLayerEntry> {
self.layers.iter().rev().collect()
self.get_layers(ConfigLayerStackOrdering::HighestPrecedenceFirst)
}
/// Returns the highest-precedence to lowest-precedence layers, so
/// `ConfigLayerSource::SessionFlags` would be first, if present.
pub fn get_layers(&self, ordering: ConfigLayerStackOrdering) -> Vec<&ConfigLayerEntry> {
match ordering {
ConfigLayerStackOrdering::HighestPrecedenceFirst => self.layers.iter().rev().collect(),
ConfigLayerStackOrdering::LowestPrecedenceFirst => self.layers.iter().collect(),
}
}
}

View file

@ -6,6 +6,8 @@ use std::sync::Arc;
use arc_swap::ArcSwap;
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use codex_execpolicy::AmendError;
use codex_execpolicy::Decision;
use codex_execpolicy::Error as ExecPolicyRuleError;
@ -94,9 +96,9 @@ impl ExecPolicyManager {
pub(crate) async fn load(
features: &Features,
codex_home: &Path,
config_stack: &ConfigLayerStack,
) -> Result<Self, ExecPolicyError> {
let policy = load_exec_policy_for_features(features, codex_home).await?;
let policy = load_exec_policy_for_features(features, config_stack).await?;
Ok(Self::new(Arc::new(policy)))
}
@ -194,18 +196,28 @@ impl Default for ExecPolicyManager {
async fn load_exec_policy_for_features(
features: &Features,
codex_home: &Path,
config_stack: &ConfigLayerStack,
) -> Result<Policy, ExecPolicyError> {
if !features.enabled(Feature::ExecPolicy) {
Ok(Policy::empty())
} else {
load_exec_policy(codex_home).await
load_exec_policy(config_stack).await
}
}
pub async fn load_exec_policy(codex_home: &Path) -> Result<Policy, ExecPolicyError> {
let policy_dir = codex_home.join(RULES_DIR_NAME);
let policy_paths = collect_policy_files(&policy_dir).await?;
pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy, ExecPolicyError> {
// Iterate the layers in increasing order of precedence, adding the *.rules
// from each layer, so that higher-precedence layers can override
// rules defined in lower-precedence ones.
let mut policy_paths = Vec::new();
for layer in config_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst) {
if let Some(config_folder) = layer.config_folder() {
#[expect(clippy::expect_used)]
let policy_dir = config_folder.join(RULES_DIR_NAME).expect("safe join");
let layer_policy_paths = collect_policy_files(&policy_dir).await?;
policy_paths.extend(layer_policy_paths);
}
}
let mut parser = PolicyParser::new();
for policy_path in &policy_paths {
@ -226,11 +238,7 @@ pub async fn load_exec_policy(codex_home: &Path) -> Result<Policy, ExecPolicyErr
}
let policy = parser.build();
tracing::debug!(
"loaded execpolicy from {} files in {}",
policy_paths.len(),
policy_dir.display()
);
tracing::debug!("loaded execpolicy from {} files", policy_paths.len());
Ok(policy)
}
@ -302,7 +310,8 @@ fn derive_prompt_reason(evaluation: &Evaluation) -> Option<String> {
})
}
async fn collect_policy_files(dir: &Path) -> Result<Vec<PathBuf>, ExecPolicyError> {
async fn collect_policy_files(dir: impl AsRef<Path>) -> Result<Vec<PathBuf>, ExecPolicyError> {
let dir = dir.as_ref();
let mut read_dir = match fs::read_dir(dir).await {
Ok(read_dir) => read_dir,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
@ -345,28 +354,51 @@ async fn collect_policy_files(dir: &Path) -> Result<Vec<PathBuf>, ExecPolicyErro
policy_paths.sort();
tracing::debug!(
"loaded {} .rules files in {}",
policy_paths.len(),
dir.display()
);
Ok(policy_paths)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirements;
use crate::features::Feature;
use crate::features::Features;
use codex_app_server_protocol::ConfigLayerSource;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use tempfile::tempdir;
use toml::Value as TomlValue;
fn config_stack_for_dot_codex_folder(dot_codex_folder: &Path) -> ConfigLayerStack {
let dot_codex_folder = AbsolutePathBuf::from_absolute_path(dot_codex_folder)
.expect("absolute dot_codex_folder");
let layer = ConfigLayerEntry::new(
ConfigLayerSource::Project { dot_codex_folder },
TomlValue::Table(Default::default()),
);
ConfigLayerStack::new(vec![layer], ConfigRequirements::default()).expect("ConfigLayerStack")
}
#[tokio::test]
async fn returns_empty_policy_when_feature_disabled() {
let mut features = Features::with_defaults();
features.disable(Feature::ExecPolicy);
let temp_dir = tempdir().expect("create temp dir");
let config_stack = config_stack_for_dot_codex_folder(temp_dir.path());
let manager = ExecPolicyManager::load(&features, temp_dir.path())
let manager = ExecPolicyManager::load(&features, &config_stack)
.await
.expect("manager result");
let policy = manager.current();
@ -400,6 +432,7 @@ mod tests {
#[tokio::test]
async fn loads_policies_from_policy_subdirectory() {
let temp_dir = tempdir().expect("create temp dir");
let config_stack = config_stack_for_dot_codex_folder(temp_dir.path());
let policy_dir = temp_dir.path().join(RULES_DIR_NAME);
fs::create_dir_all(&policy_dir).expect("create policy dir");
fs::write(
@ -408,7 +441,7 @@ mod tests {
)
.expect("write policy file");
let policy = load_exec_policy(temp_dir.path())
let policy = load_exec_policy(&config_stack)
.await
.expect("policy result");
let command = [vec!["rm".to_string()]];
@ -427,13 +460,14 @@ mod tests {
#[tokio::test]
async fn ignores_policies_outside_policy_dir() {
let temp_dir = tempdir().expect("create temp dir");
let config_stack = config_stack_for_dot_codex_folder(temp_dir.path());
fs::write(
temp_dir.path().join("root.rules"),
r#"prefix_rule(pattern=["ls"], decision="prompt")"#,
)
.expect("write policy file");
let policy = load_exec_policy(temp_dir.path())
let policy = load_exec_policy(&config_stack)
.await
.expect("policy result");
let command = [vec!["ls".to_string()]];
@ -449,6 +483,69 @@ mod tests {
);
}
#[tokio::test]
async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> {
let user_dir = tempdir()?;
let project_dir = tempdir()?;
let user_policy_dir = user_dir.path().join(RULES_DIR_NAME);
fs::create_dir_all(&user_policy_dir)?;
fs::write(
user_policy_dir.join("user.rules"),
r#"prefix_rule(pattern=["rm"], decision="forbidden")"#,
)?;
let project_policy_dir = project_dir.path().join(RULES_DIR_NAME);
fs::create_dir_all(&project_policy_dir)?;
fs::write(
project_policy_dir.join("project.rules"),
r#"prefix_rule(pattern=["ls"], decision="prompt")"#,
)?;
let user_config_toml =
AbsolutePathBuf::from_absolute_path(user_dir.path().join("config.toml"))?;
let project_dot_codex_folder = AbsolutePathBuf::from_absolute_path(project_dir.path())?;
let layers = vec![
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_config_toml,
},
TomlValue::Table(Default::default()),
),
ConfigLayerEntry::new(
ConfigLayerSource::Project {
dot_codex_folder: project_dot_codex_folder,
},
TomlValue::Table(Default::default()),
),
];
let config_stack = ConfigLayerStack::new(layers, ConfigRequirements::default())?;
let policy = load_exec_policy(&config_stack).await?;
assert_eq!(
Evaluation {
decision: Decision::Forbidden,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden
}],
},
policy.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow)
);
assert_eq!(
Evaluation {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["ls".to_string()],
decision: Decision::Prompt
}],
},
policy.check_multiple([vec!["ls".to_string()]].iter(), &|_| Decision::Allow)
);
Ok(())
}
#[tokio::test]
async fn evaluates_bash_lc_inner_commands() {
let policy_src = r#"

View file

@ -230,7 +230,21 @@ fn format_program_name(path: &Path, preserve_program_paths: bool) -> Option<Stri
async fn load_exec_policy() -> anyhow::Result<Policy> {
let codex_home = find_codex_home().context("failed to resolve codex_home for execpolicy")?;
codex_core::load_exec_policy(&codex_home)
// TODO(mbolin): At a minimum, `cwd` should be configurable via
// `codex/sandbox-state/update` or some other custom MCP call.
let cwd = None;
let cli_overrides = Vec::new();
let overrides = codex_core::config_loader::LoaderOverrides::default();
let config_layer_stack = codex_core::config_loader::load_config_layers_state(
&codex_home,
cwd,
&cli_overrides,
overrides,
)
.await?;
codex_core::load_exec_policy(&config_layer_stack)
.await
.map_err(anyhow::Error::from)
}

View file

@ -43,6 +43,13 @@ impl AbsolutePathBuf {
Self::resolve_path_against_base(path, &self.0)
}
pub fn parent(&self) -> Option<Self> {
self.0.parent().map(|p| {
#[expect(clippy::expect_used)]
Self::from_absolute_path(p).expect("parent of AbsolutePathBuf must be absolute")
})
}
pub fn as_path(&self) -> &Path {
&self.0
}