Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions codex-rs/core/src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::auth::AuthCredentialsStoreMode;
use crate::config::types::DEFAULT_OTEL_ENVIRONMENT;
use crate::config::types::ExecPolicyConfigToml;
use crate::config::types::History;
use crate::config::types::McpServerConfig;
use crate::config::types::Notice;
Expand Down Expand Up @@ -701,6 +702,9 @@ pub struct ConfigToml {
/// Default approval policy for executing commands.
pub approval_policy: Option<AskForApproval>,

/// Optional execpolicy configuration.
pub execpolicy: Option<ExecPolicyConfigToml>,

#[serde(default)]
pub shell_environment_policy: ShellEnvironmentPolicyToml,

Expand Down
13 changes: 7 additions & 6 deletions codex-rs/core/src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ use serde::de::Error as SerdeError;

pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";

#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct ExecPolicyConfigToml {
pub auto_allow_prefixes: Option<Vec<String>>,
}

#[derive(Serialize, Debug, Clone, PartialEq)]
pub struct McpServerConfig {
#[serde(flatten)]
Expand Down Expand Up @@ -389,21 +394,17 @@ impl Default for Notifications {
/// infer wheel vs trackpad per stream, or forces a specific behavior.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum ScrollInputMode {
/// Infer wheel vs trackpad behavior per scroll stream.
#[default]
Auto,
/// Always treat scroll events as mouse-wheel input (fixed lines per tick).
Wheel,
/// Always treat scroll events as trackpad input (fractional accumulation).
Trackpad,
}

impl Default for ScrollInputMode {
fn default() -> Self {
Self::Auto
}
}

/// Collection of settings that are specific to the TUI.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
pub struct Tui {
Expand Down
231 changes: 230 additions & 1 deletion codex-rs/core/src/exec_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::sync::Arc;
use arc_swap::ArcSwap;

use crate::command_safety::is_dangerous_command::requires_initial_appoval;
use crate::config::types::ExecPolicyConfigToml;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use codex_execpolicy::AmendError;
Expand All @@ -28,6 +29,7 @@ use crate::features::Feature;
use crate::features::Features;
use crate::sandboxing::SandboxPermissions;
use crate::tools::sandboxing::ExecApprovalRequirement;
use shlex::split as shlex_split;
use shlex::try_join as shlex_try_join;

const PROMPT_CONFLICT_REASON: &str =
Expand Down Expand Up @@ -200,7 +202,9 @@ async fn load_exec_policy_for_features(
if !features.enabled(Feature::ExecPolicy) {
Ok(Policy::empty())
} else {
load_exec_policy(config_stack).await
let mut policy = load_exec_policy(config_stack).await?;
apply_auto_allow_prefixes(&mut policy, config_stack);
Ok(policy)
}
}

Expand Down Expand Up @@ -242,6 +246,57 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
Ok(policy)
}

fn apply_auto_allow_prefixes(policy: &mut Policy, config_stack: &ConfigLayerStack) {
for prefix in execpolicy_auto_allow_prefixes(config_stack) {
if prefix.trim().is_empty() {
continue;
}

match shlex_split(&prefix) {
Some(argv) if argv.is_empty() => {
tracing::warn!(
prefix = %prefix,
"execpolicy auto-allow prefix resolved to empty argv; skipping"
);
}
Some(argv) => {
if let Err(err) = policy.add_prefix_rule(&argv, Decision::Allow) {
tracing::warn!(
prefix = %prefix,
error = %err,
"failed to add execpolicy auto-allow prefix"
);
}
}
None => {
tracing::warn!(
prefix = %prefix,
"execpolicy auto-allow prefix failed to parse; skipping"
);
}
}
}
}

fn execpolicy_auto_allow_prefixes(config_stack: &ConfigLayerStack) -> Vec<String> {
let Some(execpolicy) = config_stack.effective_config().get("execpolicy").cloned() else {
return Vec::new();
};

let execpolicy_config: ExecPolicyConfigToml = match execpolicy.try_into() {
Ok(config) => config,
Err(err) => {
tracing::warn!(
error = %err,
"failed to parse execpolicy config; ignoring auto-allow prefixes"
);
return Vec::new();
}
};

execpolicy_config.auto_allow_prefixes.unwrap_or_default()
}

fn default_policy_path(codex_home: &Path) -> PathBuf {
codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE)
}
Expand Down Expand Up @@ -418,6 +473,7 @@ async fn collect_policy_files(dir: impl AsRef<Path>) -> Result<Vec<PathBuf>, Exe
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirements;
Expand Down Expand Up @@ -450,6 +506,11 @@ mod tests {
.expect("ConfigLayerStack")
}

fn config_entry_from_str(source: ConfigLayerSource, toml: &str) -> ConfigLayerEntry {
let config: TomlValue = toml::from_str(toml).expect("parse toml");
ConfigLayerEntry::new(source, config)
}

#[tokio::test]
async fn returns_empty_policy_when_feature_disabled() {
let mut features = Features::with_defaults();
Expand Down Expand Up @@ -517,6 +578,174 @@ mod tests {
);
}

#[tokio::test]
async fn auto_allow_prefixes_allow_exec_commands() {
let temp_dir = tempdir().expect("create temp dir");
let dot_codex_folder = temp_dir.path().join(".codex");
let config_stack = ConfigLayerStack::new(
vec![config_entry_from_str(
ConfigLayerSource::Project {
dot_codex_folder: AbsolutePathBuf::from_absolute_path(&dot_codex_folder)
.expect("absolute dot_codex_folder"),
},
r#"
[execpolicy]
auto_allow_prefixes = ["git status"]
"#,
)],
ConfigRequirements::default(),
)
.expect("ConfigLayerStack");

let manager = ExecPolicyManager::load(&Features::with_defaults(), &config_stack)
.await
.expect("manager");
let policy = manager.current();

let command = vec![
"git".to_string(),
"status".to_string(),
"--short".to_string(),
];
let evaluation = policy.check(&command, &|_| Decision::Prompt);

assert_eq!(
evaluation,
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["git".to_string(), "status".to_string()],
decision: Decision::Allow,
justification: None,
}],
}
);
}

#[tokio::test]
async fn auto_allow_prefixes_respect_layer_precedence() {
let temp_dir = tempdir().expect("create temp dir");
let user_file = AbsolutePathBuf::from_absolute_path(temp_dir.path().join(CONFIG_TOML_FILE))
.expect("absolute user config file");
let project_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path().join(".codex"))
.expect("absolute dot_codex_folder");

let config_stack = ConfigLayerStack::new(
vec![
config_entry_from_str(
ConfigLayerSource::User { file: user_file },
r#"
[execpolicy]
auto_allow_prefixes = ["git status"]
"#,
),
config_entry_from_str(
ConfigLayerSource::Project {
dot_codex_folder: project_folder,
},
r#"
[execpolicy]
auto_allow_prefixes = ["cargo test"]
"#,
),
],
ConfigRequirements::default(),
)
.expect("ConfigLayerStack");

let manager = ExecPolicyManager::load(&Features::with_defaults(), &config_stack)
.await
.expect("manager");
let policy = manager.current();

let git_command = vec![
"git".to_string(),
"status".to_string(),
"--short".to_string(),
];
let git_evaluation = policy.check(&git_command, &|_| Decision::Prompt);
assert_eq!(
git_evaluation,
Evaluation {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
command: git_command,
decision: Decision::Prompt,
}],
}
);

let cargo_command = vec!["cargo".to_string(), "test".to_string(), "--all".to_string()];
let cargo_evaluation = policy.check(&cargo_command, &|_| Decision::Prompt);
assert_eq!(
cargo_evaluation,
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["cargo".to_string(), "test".to_string()],
decision: Decision::Allow,
justification: None,
}],
}
);
}

#[tokio::test]
async fn auto_allow_prefixes_ignores_empty_and_invalid_entries() {
let temp_dir = tempdir().expect("create temp dir");
let dot_codex_folder = temp_dir.path().join(".codex");
let config_stack = ConfigLayerStack::new(
vec![config_entry_from_str(
ConfigLayerSource::Project {
dot_codex_folder: AbsolutePathBuf::from_absolute_path(&dot_codex_folder)
.expect("absolute dot_codex_folder"),
},
r#"
[execpolicy]
auto_allow_prefixes = [" ", "git status", "git \"status"]
"#,
)],
ConfigRequirements::default(),
)
.expect("ConfigLayerStack");

let manager = ExecPolicyManager::load(&Features::with_defaults(), &config_stack)
.await
.expect("manager");
let policy = manager.current();

let git_command = vec![
"git".to_string(),
"status".to_string(),
"--short".to_string(),
];
let git_evaluation = policy.check(&git_command, &|_| Decision::Prompt);
assert_eq!(
git_evaluation,
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["git".to_string(), "status".to_string()],
decision: Decision::Allow,
justification: None,
}],
}
);

let invalid_command = vec!["git".to_string(), "\"status".to_string()];
let invalid_evaluation = policy.check(&invalid_command, &|_| Decision::Prompt);
assert_eq!(
invalid_evaluation,
Evaluation {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
command: invalid_command,
decision: Decision::Prompt,
}],
}
);
}

#[tokio::test]
async fn ignores_policies_outside_policy_dir() {
let temp_dir = tempdir().expect("create temp dir");
Expand Down
9 changes: 5 additions & 4 deletions codex-rs/core/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ mod detect_shell_type_tests {
#[cfg(unix)]
mod tests {
use super::*;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;

Expand Down Expand Up @@ -369,9 +370,9 @@ mod tests {
let shell_path = bash_shell.shell_path;

assert!(
shell_path == PathBuf::from("/bin/bash")
|| shell_path == PathBuf::from("/usr/bin/bash")
|| shell_path == PathBuf::from("/usr/local/bin/bash"),
shell_path == Path::new("/bin/bash")
|| shell_path == Path::new("/usr/bin/bash")
|| shell_path == Path::new("/usr/local/bin/bash"),
"shell path: {shell_path:?}",
);
}
Expand All @@ -381,7 +382,7 @@ mod tests {
let sh_shell = get_shell(ShellType::Sh, None).unwrap();
let shell_path = sh_shell.shell_path;
assert!(
shell_path == PathBuf::from("/bin/sh") || shell_path == PathBuf::from("/usr/bin/sh"),
shell_path == Path::new("/bin/sh") || shell_path == Path::new("/usr/bin/sh"),
"shell path: {shell_path:?}",
);
}
Expand Down
8 changes: 2 additions & 6 deletions codex-rs/core/src/tools/handlers/read_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ struct ReadFileArgs {

#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
enum ReadMode {
#[default]
Slice,
Indentation,
}
Expand Down Expand Up @@ -461,12 +463,6 @@ mod defaults {
}
}

impl Default for ReadMode {
fn default() -> Self {
Self::Slice
}
}

pub fn offset() -> usize {
1
}
Expand Down
Loading
Loading