mirror of
https://github.com/openai/codex.git
synced 2026-05-10 22:32:36 +00:00
Compare commits
2 Commits
ruslan/exe
...
dh--permis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e94b6895e2 | ||
|
|
c465d44e88 |
@@ -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(),
|
||||
|
||||
@@ -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}")))?,
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user