diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 383a9cf48..e4fe78a3d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1389,6 +1389,7 @@ dependencies = [ "libc", "maplit", "mcp-types", + "multimap", "once_cell", "openssl-sys", "os_info", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index c151356e3..c186758cb 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -55,6 +55,7 @@ indoc = { workspace = true } keyring = { workspace = true, features = ["crypto-rust"] } libc = { workspace = true } mcp-types = { workspace = true } +multimap = { workspace = true } once_cell = { workspace = true } os_info = { workspace = true } rand = { workspace = true } diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index ae56c384e..5db10f280 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -6,6 +6,8 @@ mod layer_io; mod macos; mod merge; mod overrides; +#[cfg(test)] +mod requirements_exec_policy; mod state; #[cfg(test)] diff --git a/codex-rs/core/src/config_loader/requirements_exec_policy.rs b/codex-rs/core/src/config_loader/requirements_exec_policy.rs new file mode 100644 index 000000000..cbc1d7531 --- /dev/null +++ b/codex-rs/core/src/config_loader/requirements_exec_policy.rs @@ -0,0 +1,188 @@ +use codex_execpolicy::Decision; +use codex_execpolicy::Policy; +use codex_execpolicy::rule::PatternToken; +use codex_execpolicy::rule::PrefixPattern; +use codex_execpolicy::rule::PrefixRule; +use codex_execpolicy::rule::RuleRef; +use multimap::MultiMap; +use serde::Deserialize; +use std::sync::Arc; +use thiserror::Error; + +/// TOML types for expressing exec policy requirements. +/// +/// These types are kept separate from `ConfigRequirementsToml` and are +/// converted into `codex-execpolicy` rules. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RequirementsExecPolicyTomlRoot { + pub exec_policy: RequirementsExecPolicyToml, +} + +/// TOML representation of `[exec_policy]` within `requirements.toml`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RequirementsExecPolicyToml { + pub prefix_rules: Vec, +} + +/// A TOML representation of the `prefix_rule(...)` Starlark builtin. +/// +/// This mirrors the builtin defined in `execpolicy/src/parser.rs`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RequirementsExecPolicyPrefixRuleToml { + pub pattern: Vec, + pub decision: Option, + pub justification: Option, +} + +/// TOML-friendly representation of a pattern token. +/// +/// Starlark supports either a string token or a list of alternative tokens at +/// each position, but TOML arrays cannot mix strings and arrays. Using an +/// array of tables sidesteps that restriction. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RequirementsExecPolicyPatternTokenToml { + pub token: Option, + pub any_of: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RequirementsExecPolicyDecisionToml { + Allow, + Prompt, + Forbidden, +} + +impl RequirementsExecPolicyDecisionToml { + fn as_decision(self) -> Decision { + match self { + Self::Allow => Decision::Allow, + Self::Prompt => Decision::Prompt, + Self::Forbidden => Decision::Forbidden, + } + } +} + +#[derive(Debug, Error)] +pub enum RequirementsExecPolicyParseError { + #[error("exec policy prefix_rules cannot be empty")] + EmptyPrefixRules, + + #[error("exec policy prefix_rule at index {rule_index} has an empty pattern")] + EmptyPattern { rule_index: usize }, + + #[error( + "exec policy prefix_rule at index {rule_index} has an invalid pattern token at index {token_index}: {reason}" + )] + InvalidPatternToken { + rule_index: usize, + token_index: usize, + reason: String, + }, + + #[error("exec policy prefix_rule at index {rule_index} has an empty justification")] + EmptyJustification { rule_index: usize }, +} + +impl RequirementsExecPolicyToml { + /// Convert requirements TOML exec policy rules into the internal `.rules` + /// representation used by `codex-execpolicy`. + pub fn to_policy(&self) -> Result { + if self.prefix_rules.is_empty() { + return Err(RequirementsExecPolicyParseError::EmptyPrefixRules); + } + + let mut rules_by_program: MultiMap = MultiMap::new(); + + for (rule_index, rule) in self.prefix_rules.iter().enumerate() { + if let Some(justification) = &rule.justification + && justification.trim().is_empty() + { + return Err(RequirementsExecPolicyParseError::EmptyJustification { rule_index }); + } + + if rule.pattern.is_empty() { + return Err(RequirementsExecPolicyParseError::EmptyPattern { rule_index }); + } + + let pattern_tokens = rule + .pattern + .iter() + .enumerate() + .map(|(token_index, token)| parse_pattern_token(token, rule_index, token_index)) + .collect::, _>>()?; + + let decision = rule + .decision + .map(RequirementsExecPolicyDecisionToml::as_decision) + .unwrap_or(Decision::Allow); + let justification = rule.justification.clone(); + + let (first_token, remaining_tokens) = pattern_tokens + .split_first() + .ok_or(RequirementsExecPolicyParseError::EmptyPattern { rule_index })?; + + let rest: Arc<[PatternToken]> = remaining_tokens.to_vec().into(); + + for head in first_token.alternatives() { + let rule: RuleRef = Arc::new(PrefixRule { + pattern: PrefixPattern { + first: Arc::from(head.as_str()), + rest: rest.clone(), + }, + decision, + justification: justification.clone(), + }); + rules_by_program.insert(head.clone(), rule); + } + } + + Ok(Policy::new(rules_by_program)) + } +} + +fn parse_pattern_token( + token: &RequirementsExecPolicyPatternTokenToml, + rule_index: usize, + token_index: usize, +) -> Result { + match (&token.token, &token.any_of) { + (Some(single), None) => { + if single.trim().is_empty() { + return Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "token cannot be empty".to_string(), + }); + } + Ok(PatternToken::Single(single.clone())) + } + (None, Some(alternatives)) => { + if alternatives.is_empty() { + return Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "any_of cannot be empty".to_string(), + }); + } + if alternatives.iter().any(|alt| alt.trim().is_empty()) { + return Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "any_of cannot include empty tokens".to_string(), + }); + } + Ok(PatternToken::Alts(alternatives.clone())) + } + (Some(_), Some(_)) => Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "set either token or any_of, not both".to_string(), + }), + (None, None) => Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "set either token or any_of".to_string(), + }), + } +} diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 03b0706c8..4b512c150 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -911,3 +911,165 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() Ok(()) } + +mod requirements_exec_policy_tests { + use super::super::requirements_exec_policy::RequirementsExecPolicyDecisionToml; + use super::super::requirements_exec_policy::RequirementsExecPolicyPatternTokenToml; + use super::super::requirements_exec_policy::RequirementsExecPolicyPrefixRuleToml; + use super::super::requirements_exec_policy::RequirementsExecPolicyToml; + use super::super::requirements_exec_policy::RequirementsExecPolicyTomlRoot; + use codex_execpolicy::Decision; + use codex_execpolicy::Evaluation; + use codex_execpolicy::RuleMatch; + use pretty_assertions::assert_eq; + use toml::from_str; + + fn tokens(cmd: &[&str]) -> Vec { + cmd.iter().map(std::string::ToString::to_string).collect() + } + + fn allow_all(_: &[String]) -> Decision { + Decision::Allow + } + + #[test] + fn parses_single_prefix_rule_from_raw_toml() -> anyhow::Result<()> { + let toml_str = r#" +[exec_policy] +prefix_rules = [ + { pattern = [{ token = "rm" }], decision = "forbidden" }, +] +"#; + + let parsed: RequirementsExecPolicyTomlRoot = from_str(toml_str)?; + + assert_eq!( + parsed, + RequirementsExecPolicyTomlRoot { + exec_policy: RequirementsExecPolicyToml { + prefix_rules: vec![RequirementsExecPolicyPrefixRuleToml { + pattern: vec![RequirementsExecPolicyPatternTokenToml { + token: Some("rm".to_string()), + any_of: None, + }], + decision: Some(RequirementsExecPolicyDecisionToml::Forbidden), + justification: None, + }], + }, + } + ); + + Ok(()) + } + + #[test] + fn parses_multiple_prefix_rules_from_raw_toml() -> anyhow::Result<()> { + let toml_str = r#" +[exec_policy] +prefix_rules = [ + { pattern = [{ token = "rm" }], decision = "forbidden" }, + { pattern = [{ token = "git" }, { any_of = ["push", "commit"] }], decision = "prompt", justification = "review changes before push or commit" }, +] +"#; + + let parsed: RequirementsExecPolicyTomlRoot = from_str(toml_str)?; + + assert_eq!( + parsed, + RequirementsExecPolicyTomlRoot { + exec_policy: RequirementsExecPolicyToml { + prefix_rules: vec![ + RequirementsExecPolicyPrefixRuleToml { + pattern: vec![RequirementsExecPolicyPatternTokenToml { + token: Some("rm".to_string()), + any_of: None, + }], + decision: Some(RequirementsExecPolicyDecisionToml::Forbidden), + justification: None, + }, + RequirementsExecPolicyPrefixRuleToml { + pattern: vec![ + RequirementsExecPolicyPatternTokenToml { + token: Some("git".to_string()), + any_of: None, + }, + RequirementsExecPolicyPatternTokenToml { + token: None, + any_of: Some(vec!["push".to_string(), "commit".to_string()]), + }, + ], + decision: Some(RequirementsExecPolicyDecisionToml::Prompt), + justification: Some("review changes before push or commit".to_string()), + }, + ], + }, + } + ); + + Ok(()) + } + + #[test] + fn converts_rules_toml_into_internal_policy_representation() -> anyhow::Result<()> { + let toml_str = r#" +[exec_policy] +prefix_rules = [ + { pattern = [{ token = "rm" }], decision = "forbidden" }, +] +"#; + + let parsed: RequirementsExecPolicyTomlRoot = from_str(toml_str)?; + let policy = parsed.exec_policy.to_policy()?; + + assert_eq!( + policy.check(&tokens(&["rm", "-rf", "/tmp"]), &allow_all), + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["rm"]), + decision: Decision::Forbidden, + justification: None, + }], + } + ); + + Ok(()) + } + + #[test] + fn head_any_of_expands_into_multiple_program_rules() -> anyhow::Result<()> { + let toml_str = r#" +[exec_policy] +prefix_rules = [ + { pattern = [{ any_of = ["git", "hg"] }, { token = "status" }], decision = "prompt" }, +] +"#; + let parsed: RequirementsExecPolicyTomlRoot = from_str(toml_str)?; + let policy = parsed.exec_policy.to_policy()?; + + assert_eq!( + policy.check(&tokens(&["git", "status"]), &allow_all), + Evaluation { + decision: Decision::Prompt, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["git", "status"]), + decision: Decision::Prompt, + justification: None, + }], + } + ); + assert_eq!( + policy.check(&tokens(&["hg", "status"]), &allow_all), + Evaluation { + decision: Decision::Prompt, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["hg", "status"]), + decision: Decision::Prompt, + justification: None, + }], + } + ); + + Ok(()) + } +}