Compare commits

...

2 Commits

Author SHA1 Message Date
Dylan Hurd
e94b6895e2 docs: describe profile-scoped rules
Co-authored-by: Codex <noreply@openai.com>
2026-04-13 15:40:02 -07:00
Dylan Hurd
c465d44e88 chore(codex) per-profile exec rules 2026-04-13 15:40:02 -07:00
13 changed files with 342 additions and 33 deletions

View File

@@ -441,7 +441,12 @@ pub async fn run_main_with_transport(
}
};
if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await {
if let Ok(Some(err)) = check_execpolicy_for_warnings(
&config.config_layer_stack,
config.permissions.active_profile_name.as_deref(),
)
.await
{
let (path, range) = exec_policy_warning_location(&err);
let message = ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),

View File

@@ -559,9 +559,12 @@ impl Codex {
Arc::clone(exec_policy)
} else {
Arc::new(
ExecPolicyManager::load(&config.config_layer_stack)
.await
.map_err(|err| CodexErr::Fatal(format!("failed to load rules: {err}")))?,
ExecPolicyManager::load(
&config.config_layer_stack,
config.permissions.active_profile_name.as_deref(),
)
.await
.map_err(|err| CodexErr::Fatal(format!("failed to load rules: {err}")))?,
)
};

View File

@@ -397,9 +397,12 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
.expect("config layer stack");
let command = [vec!["rm".to_string()]];
let parent_exec_policy = ExecPolicyManager::load(&config.config_layer_stack)
.await
.expect("load parent exec policy");
let parent_exec_policy = ExecPolicyManager::load(
&config.config_layer_stack,
config.permissions.active_profile_name.as_deref(),
)
.await
.expect("load parent exec policy");
assert_eq!(
parent_exec_policy
.current()

View File

@@ -597,6 +597,10 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re
codex_home.abs(),
)?;
assert_eq!(
config.permissions.active_profile_name.as_deref(),
Some("workspace")
);
let memories_root = codex_home.path().join("memories").abs();
assert_eq!(
config.permissions.file_system_sandbox_policy,
@@ -4562,6 +4566,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
permissions: Permissions {
active_profile_name: None,
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
@@ -4711,6 +4716,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
model_provider_id: "openai-custom".to_string(),
model_provider: fixture.openai_custom_provider.clone(),
permissions: Permissions {
active_profile_name: None,
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
@@ -4858,6 +4864,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
permissions: Permissions {
active_profile_name: None,
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(
@@ -4991,6 +4998,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
permissions: Permissions {
active_profile_name: None,
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(

View File

@@ -182,6 +182,9 @@ pub(crate) fn test_config() -> Config {
/// Application configuration loaded from disk and merged with overrides.
#[derive(Debug, Clone, PartialEq)]
pub struct Permissions {
/// Active named permissions profile, when profile-based permissions are in
/// use.
pub active_profile_name: Option<String>,
/// Approval policy for executing commands.
pub approval_policy: Constrained<AskForApproval>,
/// Effective sandbox policy used for shell/unified exec.
@@ -1589,6 +1592,7 @@ impl Config {
) || (permission_config_syntax.is_none()
&& has_permission_profiles);
let (
active_permission_profile_name,
configured_network_proxy_config,
sandbox_policy,
file_system_sandbox_policy,
@@ -1627,6 +1631,7 @@ impl Config {
.to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?;
}
(
Some(default_permissions.to_string()),
configured_network_proxy_config,
sandbox_policy,
file_system_sandbox_policy,
@@ -1654,6 +1659,7 @@ impl Config {
);
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
(
None,
configured_network_proxy_config,
sandbox_policy,
file_system_sandbox_policy,
@@ -2019,6 +2025,7 @@ impl Config {
cwd: resolved_cwd,
startup_warnings,
permissions: Permissions {
active_profile_name: active_permission_profile_name,
approval_policy: constrained_approval_policy.value,
sandbox_policy: constrained_sandbox_policy.value,
file_system_sandbox_policy: effective_file_system_sandbox_policy,

View File

@@ -1720,7 +1720,8 @@ prefix_rules = []
let config_stack =
config_stack_for_dot_codex_folder_with_requirements(temp_dir.path(), requirements);
let policy = load_exec_policy(&config_stack).await?;
let policy =
load_exec_policy(&config_stack, /*active_permission_profile_name*/ None).await?;
assert_eq!(
policy.check_multiple([vec!["rm".to_string()]].iter(), &panic_if_called),
@@ -1759,7 +1760,8 @@ prefix_rules = []
let config_stack =
config_stack_for_dot_codex_folder_with_requirements(temp_dir.path(), requirements);
let policy = load_exec_policy(&config_stack).await?;
let policy =
load_exec_policy(&config_stack, /*active_permission_profile_name*/ None).await?;
assert_eq!(
policy.check_multiple([vec!["rm".to_string()]].iter(), &panic_if_called),

View File

@@ -112,6 +112,8 @@ pub(crate) fn child_uses_parent_exec_policy(parent_config: &Config, child_config
exec_policy_config_folders(parent_config) == exec_policy_config_folders(child_config)
&& parent_config.config_layer_stack.requirements().exec_policy
== child_config.config_layer_stack.requirements().exec_policy
&& parent_config.permissions.active_profile_name
== child_config.permissions.active_profile_name
}
fn is_policy_match(rule_match: &RuleMatch) -> bool {
@@ -171,6 +173,9 @@ pub enum ExecPolicyError {
path: String,
source: codex_execpolicy::Error,
},
#[error("invalid permissions profile name `{profile_name}` for rules file")]
InvalidProfileName { profile_name: String },
}
#[derive(Debug, Error)]
@@ -178,6 +183,9 @@ pub enum ExecPolicyUpdateError {
#[error("failed to update rules file {path}: {source}")]
AppendRule { path: PathBuf, source: AmendError },
#[error("failed to resolve rules file path: {source}")]
RulePath { source: ExecPolicyError },
#[error("failed to join blocking rules update task: {source}")]
JoinBlockingTask { source: tokio::task::JoinError },
@@ -190,6 +198,7 @@ pub enum ExecPolicyUpdateError {
pub(crate) struct ExecPolicyManager {
policy: ArcSwap<Policy>,
active_permission_profile_name: Option<String>,
update_lock: tokio::sync::Mutex<()>,
}
@@ -206,17 +215,26 @@ impl ExecPolicyManager {
pub(crate) fn new(policy: Arc<Policy>) -> Self {
Self {
policy: ArcSwap::from(policy),
active_permission_profile_name: None,
update_lock: tokio::sync::Mutex::new(()),
}
}
#[instrument(level = "info", skip_all)]
pub(crate) async fn load(config_stack: &ConfigLayerStack) -> Result<Self, ExecPolicyError> {
let (policy, warning) = load_exec_policy_with_warning(config_stack).await?;
pub(crate) async fn load(
config_stack: &ConfigLayerStack,
active_permission_profile_name: Option<&str>,
) -> Result<Self, ExecPolicyError> {
let (policy, warning) =
load_exec_policy_with_warning(config_stack, active_permission_profile_name).await?;
if let Some(err) = warning.as_ref() {
tracing::warn!("failed to parse rules: {err}");
}
Ok(Self::new(Arc::new(policy)))
Ok(Self {
policy: ArcSwap::from(Arc::new(policy)),
active_permission_profile_name: active_permission_profile_name.map(str::to_string),
update_lock: tokio::sync::Mutex::new(()),
})
}
pub(crate) fn current(&self) -> Arc<Policy> {
@@ -315,7 +333,8 @@ impl ExecPolicyManager {
amendment: &ExecPolicyAmendment,
) -> Result<(), ExecPolicyUpdateError> {
let _update_guard = self.update_lock.lock().await;
let policy_path = default_policy_path(codex_home);
let policy_path =
writable_policy_path(codex_home, self.active_permission_profile_name.as_deref())?;
spawn_blocking({
let policy_path = policy_path.clone();
let prefix = amendment.command.clone();
@@ -360,7 +379,8 @@ impl ExecPolicyManager {
justification: Option<String>,
) -> Result<(), ExecPolicyUpdateError> {
let _update_guard = self.update_lock.lock().await;
let policy_path = default_policy_path(codex_home);
let policy_path =
writable_policy_path(codex_home, self.active_permission_profile_name.as_deref())?;
let host = host.to_string();
spawn_blocking({
let policy_path = policy_path.clone();
@@ -398,8 +418,10 @@ impl Default for ExecPolicyManager {
pub async fn check_execpolicy_for_warnings(
config_stack: &ConfigLayerStack,
active_permission_profile_name: Option<&str>,
) -> Result<Option<ExecPolicyError>, ExecPolicyError> {
let (_, warning) = load_exec_policy_with_warning(config_stack).await?;
let (_, warning) =
load_exec_policy_with_warning(config_stack, active_permission_profile_name).await?;
Ok(warning)
}
@@ -476,15 +498,19 @@ pub fn format_exec_policy_error_with_source(error: &ExecPolicyError) -> String {
async fn load_exec_policy_with_warning(
config_stack: &ConfigLayerStack,
active_permission_profile_name: Option<&str>,
) -> Result<(Policy, Option<ExecPolicyError>), ExecPolicyError> {
match load_exec_policy(config_stack).await {
match load_exec_policy(config_stack, active_permission_profile_name).await {
Ok(policy) => Ok((policy, None)),
Err(err @ ExecPolicyError::ParsePolicy { .. }) => Ok((Policy::empty(), Some(err))),
Err(err) => Err(err),
}
}
pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy, ExecPolicyError> {
pub async fn load_exec_policy(
config_stack: &ConfigLayerStack,
active_permission_profile_name: Option<&str>,
) -> 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.
@@ -495,7 +521,8 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
) {
if let Some(config_folder) = layer.config_folder() {
let policy_dir = config_folder.join(RULES_DIR_NAME);
let layer_policy_paths = collect_policy_files(&policy_dir).await?;
let layer_policy_paths =
collect_policy_files(&policy_dir, active_permission_profile_name).await?;
policy_paths.extend(layer_policy_paths);
}
}
@@ -630,6 +657,34 @@ fn default_policy_path(codex_home: &Path) -> PathBuf {
codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE)
}
fn profile_policy_file_name(profile_name: &str) -> Result<String, ExecPolicyError> {
if profile_name.is_empty()
|| profile_name == "."
|| profile_name == ".."
|| profile_name.contains('/')
|| profile_name.contains('\\')
{
return Err(ExecPolicyError::InvalidProfileName {
profile_name: profile_name.to_string(),
});
}
Ok(format!("{profile_name}.{RULE_EXTENSION}"))
}
fn writable_policy_path(
codex_home: &Path,
active_permission_profile_name: Option<&str>,
) -> Result<PathBuf, ExecPolicyUpdateError> {
let Some(profile_name) = active_permission_profile_name else {
return Ok(default_policy_path(codex_home));
};
let file_name = profile_policy_file_name(profile_name)
.map_err(|source| ExecPolicyUpdateError::RulePath { source })?;
Ok(codex_home.join(RULES_DIR_NAME).join(file_name))
}
fn commands_for_exec_policy(command: &[String]) -> (Vec<Vec<String>>, bool) {
if let Some(commands) = parse_shell_lc_plain_commands(command)
&& !commands.is_empty()
@@ -825,8 +880,24 @@ fn derive_forbidden_reason(command_args: &[String], evaluation: &Evaluation) ->
}
}
async fn collect_policy_files(dir: impl AsRef<Path>) -> Result<Vec<PathBuf>, ExecPolicyError> {
async fn collect_policy_files(
dir: impl AsRef<Path>,
active_permission_profile_name: Option<&str>,
) -> Result<Vec<PathBuf>, ExecPolicyError> {
let dir = dir.as_ref();
if let Some(profile_name) = active_permission_profile_name {
let policy_path = dir.join(profile_policy_file_name(profile_name)?);
return match fs::metadata(&policy_path).await {
Ok(metadata) if metadata.is_file() => Ok(vec![policy_path]),
Ok(_) => Ok(Vec::new()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(Vec::new()),
Err(source) => Err(ExecPolicyError::ReadFile {
path: policy_path,
source,
}),
};
}
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()),

View File

@@ -170,14 +170,27 @@ async fn child_does_not_use_parent_exec_policy_when_requirements_exec_policy_dif
));
}
#[tokio::test]
async fn child_does_not_use_parent_exec_policy_when_active_permission_profile_differs() {
let (_home, parent_config) = test_config().await;
let mut child_config = parent_config.clone();
child_config.permissions.active_profile_name = Some("workspace".to_string());
assert!(!child_uses_parent_exec_policy(
&parent_config,
&child_config
));
}
#[tokio::test]
async fn returns_empty_policy_when_no_policy_files_exist() {
let temp_dir = tempdir().expect("create temp dir");
let config_stack = config_stack_for_dot_codex_folder(temp_dir.path());
let manager = ExecPolicyManager::load(&config_stack)
.await
.expect("manager result");
let manager =
ExecPolicyManager::load(&config_stack, /*active_permission_profile_name*/ None)
.await
.expect("manager result");
let policy = manager.current();
let commands = [vec!["rm".to_string()]];
@@ -199,7 +212,7 @@ async fn collect_policy_files_returns_empty_when_dir_missing() {
let temp_dir = tempdir().expect("create temp dir");
let policy_dir = temp_dir.path().join(RULES_DIR_NAME);
let files = collect_policy_files(&policy_dir)
let files = collect_policy_files(&policy_dir, /*active_permission_profile_name*/ None)
.await
.expect("collect policy files");
@@ -223,7 +236,7 @@ async fn format_exec_policy_error_with_source_renders_range() {
)
.expect("write broken policy file");
let err = load_exec_policy(&config_stack)
let err = load_exec_policy(&config_stack, /*active_permission_profile_name*/ None)
.await
.expect_err("expected parse error");
let rendered = format_exec_policy_error_with_source(&err);
@@ -263,7 +276,7 @@ async fn loads_policies_from_policy_subdirectory() {
)
.expect("write policy file");
let policy = load_exec_policy(&config_stack)
let policy = load_exec_policy(&config_stack, /*active_permission_profile_name*/ None)
.await
.expect("policy result");
let command = [vec!["rm".to_string()]];
@@ -281,6 +294,100 @@ async fn loads_policies_from_policy_subdirectory() {
);
}
#[tokio::test]
async fn active_permission_profile_loads_only_matching_rules_file() {
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(
policy_dir.join("workspace.rules"),
r#"prefix_rule(pattern=["echo"], decision="forbidden")"#,
)
.expect("write workspace policy file");
fs::write(
policy_dir.join("default.rules"),
r#"prefix_rule(pattern=["rm"], decision="forbidden")"#,
)
.expect("write default policy file");
fs::write(
policy_dir.join("full.rules"),
r#"prefix_rule(pattern=["ls"], decision="prompt")"#,
)
.expect("write full policy file");
let policy = load_exec_policy(&config_stack, Some("workspace"))
.await
.expect("policy result");
assert_eq!(
Evaluation {
decision: Decision::Forbidden,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["echo".to_string()],
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
},
policy.check_multiple([vec!["echo".to_string()]].iter(), &|_| Decision::Allow)
);
assert_eq!(
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
command: vec!["rm".to_string()],
decision: Decision::Allow
}],
},
policy.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow)
);
}
#[tokio::test]
async fn active_permission_profile_allows_missing_rules_file() {
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(
policy_dir.join("default.rules"),
r#"prefix_rule(pattern=["rm"], decision="forbidden")"#,
)
.expect("write default policy file");
let policy = load_exec_policy(&config_stack, Some("workspace"))
.await
.expect("policy result");
assert_eq!(
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
command: vec!["rm".to_string()],
decision: Decision::Allow
}],
},
policy.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow)
);
}
#[tokio::test]
async fn active_permission_profile_rejects_path_like_name() {
let temp_dir = tempdir().expect("create temp dir");
let config_stack = config_stack_for_dot_codex_folder(temp_dir.path());
let err = load_exec_policy(&config_stack, Some("../workspace"))
.await
.expect_err("path-like profile name should be rejected");
assert!(matches!(
err,
ExecPolicyError::InvalidProfileName { profile_name }
if profile_name == "../workspace"
));
}
#[tokio::test]
async fn merges_requirements_exec_policy_network_rules() -> anyhow::Result<()> {
let temp_dir = tempdir()?;
@@ -308,7 +415,7 @@ async fn merges_requirements_exec_policy_network_rules() -> anyhow::Result<()> {
let config_stack =
ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?;
let policy = load_exec_policy(&config_stack).await?;
let policy = load_exec_policy(&config_stack, /*active_permission_profile_name*/ None).await?;
let (allowed, denied) = policy.compiled_network_domains();
assert!(allowed.is_empty());
@@ -355,7 +462,7 @@ host_executable(name = "git", paths = ["{git_path_literal}"])
let config_stack =
ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?;
let policy = load_exec_policy(&config_stack).await?;
let policy = load_exec_policy(&config_stack, /*active_permission_profile_name*/ None).await?;
assert_eq!(
policy
@@ -378,7 +485,7 @@ async fn ignores_policies_outside_policy_dir() {
)
.expect("write policy file");
let policy = load_exec_policy(&config_stack)
let policy = load_exec_policy(&config_stack, /*active_permission_profile_name*/ None)
.await
.expect("policy result");
let command = [vec!["ls".to_string()]];
@@ -418,7 +525,7 @@ async fn ignores_rules_from_untrusted_project_layers() -> anyhow::Result<()> {
ConfigRequirementsToml::default(),
)?;
let policy = load_exec_policy(&config_stack).await?;
let policy = load_exec_policy(&config_stack, /*active_permission_profile_name*/ None).await?;
assert_eq!(
Evaluation {
@@ -475,7 +582,7 @@ async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> {
ConfigRequirementsToml::default(),
)?;
let policy = load_exec_policy(&config_stack).await?;
let policy = load_exec_policy(&config_stack, /*active_permission_profile_name*/ None).await?;
assert_eq!(
Evaluation {
@@ -1167,6 +1274,30 @@ async fn append_execpolicy_amendment_updates_policy_and_file() {
);
}
#[tokio::test]
async fn append_execpolicy_amendment_updates_active_profile_file() {
let codex_home = tempdir().expect("create temp dir");
let config_stack = config_stack_for_dot_codex_folder(codex_home.path());
let prefix = vec!["echo".to_string(), "hello".to_string()];
let manager = ExecPolicyManager::load(&config_stack, Some("workspace"))
.await
.expect("load manager");
manager
.append_amendment_and_update(codex_home.path(), &ExecPolicyAmendment::from(prefix))
.await
.expect("update policy");
let contents = fs::read_to_string(codex_home.path().join("rules").join("workspace.rules"))
.expect("profile policy file should have been created");
assert_eq!(
contents,
r#"prefix_rule(pattern=["echo", "hello"], decision="allow")
"#
);
assert!(!default_policy_path(codex_home.path()).exists());
}
#[tokio::test]
async fn append_execpolicy_amendment_rejects_empty_prefix() {
let codex_home = tempdir().expect("create temp dir");

View File

@@ -55,7 +55,14 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec<LayerMtime
.await
.context("failed to load Codex config")?;
let (exec_policy, warning) = match load_exec_policy(&config_layer_stack).await {
let active_permission_profile_name =
active_permission_profile_from_layers(&config_layer_stack)?;
let (exec_policy, warning) = match load_exec_policy(
&config_layer_stack,
active_permission_profile_name.as_deref(),
)
.await
{
Ok(policy) => (policy, None),
Err(err @ ExecPolicyError::ParsePolicy { .. }) => {
(codex_execpolicy::Policy::empty(), Some(err))
@@ -187,6 +194,20 @@ fn network_tables_from_toml(value: &toml::Value) -> Result<NetworkTablesToml> {
.context("failed to deserialize network tables from config")
}
fn active_permission_profile_from_layers(layers: &ConfigLayerStack) -> Result<Option<String>> {
let mut active_profile_name = None;
for layer in layers.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
) {
let parsed = network_tables_from_toml(&layer.config)?;
if parsed.default_permissions.is_some() {
active_profile_name = parsed.default_permissions;
}
}
Ok(active_profile_name)
}
fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result<Option<NetworkToml>> {
let Some(default_permissions) = parsed.default_permissions else {
return Ok(None);

View File

@@ -1,9 +1,15 @@
use super::*;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use codex_app_server_protocol::ConfigLayerSource;
use codex_execpolicy::Decision;
use codex_execpolicy::NetworkRuleProtocol;
use codex_execpolicy::Policy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn higher_precedence_profile_network_overlays_domain_entries() {
@@ -147,6 +153,38 @@ fn execpolicy_network_rules_overlay_network_lists() {
);
}
#[test]
fn active_permission_profile_from_layers_uses_highest_precedence_default_permissions() {
let lower_dir = tempdir().expect("create lower dir");
let higher_dir = tempdir().expect("create higher dir");
let lower_file =
AbsolutePathBuf::from_absolute_path(lower_dir.path().join("config.toml")).unwrap();
let higher_dot_codex_folder = AbsolutePathBuf::from_absolute_path(higher_dir.path()).unwrap();
let lower_config: toml::Value =
toml::from_str(r#"default_permissions = "workspace""#).expect("lower config should parse");
let higher_config: toml::Value =
toml::from_str(r#"default_permissions = "full""#).expect("higher config should parse");
let layers = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(ConfigLayerSource::User { file: lower_file }, lower_config),
ConfigLayerEntry::new(
ConfigLayerSource::Project {
dot_codex_folder: higher_dot_codex_folder,
},
higher_config,
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack");
assert_eq!(
active_permission_profile_from_layers(&layers).expect("active profile should resolve"),
Some("full".to_string())
);
}
#[test]
fn apply_network_constraints_includes_allow_all_unix_sockets_flag() {
let config: toml::Value = toml::from_str(

View File

@@ -404,7 +404,12 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
.await?;
#[allow(clippy::print_stderr)]
match check_execpolicy_for_warnings(&config.config_layer_stack).await {
match check_execpolicy_for_warnings(
&config.config_layer_stack,
config.permissions.active_profile_name.as_deref(),
)
.await
{
Ok(None) => {}
Ok(Some(err)) | Err(err) => {
eprintln!(

View File

@@ -844,7 +844,12 @@ pub async fn run_main(
.await;
#[allow(clippy::print_stderr)]
match check_execpolicy_for_warnings(&config.config_layer_stack).await {
match check_execpolicy_for_warnings(
&config.config_layer_stack,
config.permissions.active_profile_name.as_deref(),
)
.await
{
Ok(None) => {}
Ok(Some(err)) | Err(err) => {
eprintln!(

View File

@@ -50,6 +50,16 @@ Codex can run a notification hook when the agent finishes a turn. See the config
When Codex knows which client started the turn, the legacy notify JSON payload also includes a top-level `client` field. The TUI reports `codex-tui`, and the app server reports the `clientInfo.name` value from `initialize`.
## Exec policy rules
Codex loads execution policy files from `rules/` under each active config
folder. When profile-based permissions are active via `default_permissions =
"name"`, Codex loads only `rules/name.rules` for that permissions profile and
stores newly approved command or network rules in `~/.codex/rules/name.rules`.
When no permissions profile is active, Codex keeps the legacy behavior of
loading all `*.rules` files and writing new approvals to
`~/.codex/rules/default.rules`.
## JSON Schema
The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`.