diff --git a/codex-rs/cli/tests/execpolicy.rs b/codex-rs/cli/tests/execpolicy.rs index c6bca85bc6..4610b95874 100644 --- a/codex-rs/cli/tests/execpolicy.rs +++ b/codex-rs/cli/tests/execpolicy.rs @@ -40,17 +40,15 @@ prefix_rule( assert_eq!( result, json!({ - "match": { - "decision": "forbidden", - "matchedRules": [ - { - "prefixRuleMatch": { - "matchedPrefix": ["git", "push"], - "decision": "forbidden" - } + "decision": "forbidden", + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["git", "push"], + "decision": "forbidden" } - ] - } + } + ] }) ); diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 7c96deea90..53d635b12b 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -10,6 +10,7 @@ use codex_execpolicy::Error as ExecPolicyRuleError; use codex_execpolicy::Evaluation; use codex_execpolicy::Policy; use codex_execpolicy::PolicyParser; +use codex_execpolicy::RuleMatch; use codex_execpolicy::blocking_append_allow_prefix_rule; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; @@ -25,6 +26,8 @@ use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ExecApprovalRequirement; const FORBIDDEN_REASON: &str = "execpolicy forbids this command"; +const PROMPT_CONFLICT_REASON: &str = + "execpolicy requires approval for this command, but AskForApproval is set to Never"; const PROMPT_REASON: &str = "execpolicy requires approval for this command"; const POLICY_DIR_NAME: &str = "policy"; const POLICY_EXTENSION: &str = "codexpolicy"; @@ -139,45 +142,41 @@ pub(crate) async fn append_allow_prefix_rule_and_update( Ok(()) } -fn requirement_from_decision( - decision: Decision, - approval_policy: AskForApproval, -) -> ExecApprovalRequirement { - match decision { - Decision::Forbidden => ExecApprovalRequirement::Forbidden { - reason: FORBIDDEN_REASON.to_string(), - }, - Decision::Prompt => { - let reason = PROMPT_REASON.to_string(); - if matches!(approval_policy, AskForApproval::Never) { - ExecApprovalRequirement::Forbidden { reason } - } else { - ExecApprovalRequirement::NeedsApproval { - reason: Some(reason), - allow_prefix: None, +/// Return an allow-prefix option when a command needs approval and execpolicy did not drive the decision. +fn allow_prefix_if_applicable(evaluation: &Evaluation, features: &Features) -> Option> { + if !features.enabled(Feature::ExecPolicy) || evaluation.decision != Decision::Prompt { + return None; + } + + let mut first_prompt_from_heuristics: Option> = None; + for rule_match in &evaluation.matched_rules { + match rule_match { + RuleMatch::HeuristicsRuleMatch { command, decision } => { + if *decision == Decision::Prompt && first_prompt_from_heuristics.is_none() { + first_prompt_from_heuristics = Some(command.clone()); } } + _ if rule_match.decision() == Decision::Prompt => { + return None; + } + _ => {} } - Decision::Allow => ExecApprovalRequirement::Skip { - bypass_sandbox: true, - }, } + + first_prompt_from_heuristics } -/// Return an allow-prefix option when a single plain command needs approval without -/// any matching policy rule. We only surface the prefix opt-in when execpolicy did -/// not already drive the decision (NoMatch) and when the command is a single -/// unrolled command (multi-part scripts shouldn’t be whitelisted via prefix) and -/// when execpolicy feature is enabled. -fn allow_prefix_if_applicable( - commands: &[Vec], - features: &Features, -) -> Option> { - if features.enabled(Feature::ExecPolicy) && commands.len() == 1 { - Some(commands[0].clone()) - } else { - None - } +/// Only return PROMPT_REASON when an execpolicy rule drove the prompt decision. +fn derive_prompt_reason(evaluation: &Evaluation) -> Option { + evaluation.matched_rules.iter().find_map(|rule_match| { + if !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) + && rule_match.decision() == Decision::Prompt + { + Some(PROMPT_REASON.to_string()) + } else { + None + } + }) } pub(crate) async fn create_exec_approval_requirement_for_command( @@ -189,27 +188,39 @@ pub(crate) async fn create_exec_approval_requirement_for_command( sandbox_permissions: SandboxPermissions, ) -> ExecApprovalRequirement { let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]); - let evaluation = exec_policy.read().await.check_multiple(commands.iter()); + let heuristics_fallback = |cmd: &[String]| { + if requires_initial_appoval(approval_policy, sandbox_policy, cmd, sandbox_permissions) { + Decision::Prompt + } else { + Decision::Allow + } + }; + let policy = exec_policy.read().await; + let evaluation = policy.check_multiple(commands.iter(), &heuristics_fallback); + let has_policy_allow = evaluation.matched_rules.iter().any(|rule_match| { + !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) + && rule_match.decision() == Decision::Allow + }); - match evaluation { - Evaluation::Match { decision, .. } => requirement_from_decision(decision, approval_policy), - Evaluation::NoMatch { .. } => { - if requires_initial_appoval( - approval_policy, - sandbox_policy, - command, - sandbox_permissions, - ) { - ExecApprovalRequirement::NeedsApproval { - reason: None, - allow_prefix: allow_prefix_if_applicable(&commands, features), + match evaluation.decision { + Decision::Forbidden => ExecApprovalRequirement::Forbidden { + reason: FORBIDDEN_REASON.to_string(), + }, + Decision::Prompt => { + if matches!(approval_policy, AskForApproval::Never) { + ExecApprovalRequirement::Forbidden { + reason: PROMPT_CONFLICT_REASON.to_string(), } } else { - ExecApprovalRequirement::Skip { - bypass_sandbox: false, + ExecApprovalRequirement::NeedsApproval { + reason: derive_prompt_reason(&evaluation), + allow_prefix: allow_prefix_if_applicable(&evaluation, features), } } } + Decision::Allow => ExecApprovalRequirement::Skip { + bypass_sandbox: has_policy_allow, + }, } } @@ -282,10 +293,19 @@ mod tests { .expect("policy result"); let commands = [vec!["rm".to_string()]]; - assert!(matches!( - policy.read().await.check_multiple(commands.iter()), - Evaluation::NoMatch { .. } - )); + assert_eq!( + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command: vec!["rm".to_string()], + decision: Decision::Allow + }], + }, + policy + .read() + .await + .check_multiple(commands.iter(), &|_| Decision::Allow) + ); assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists()); } @@ -316,10 +336,19 @@ mod tests { .await .expect("policy result"); let command = [vec!["rm".to_string()]]; - assert!(matches!( - policy.read().await.check_multiple(command.iter()), - Evaluation::Match { .. } - )); + assert_eq!( + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["rm".to_string()], + decision: Decision::Forbidden + }], + }, + policy + .read() + .await + .check_multiple(command.iter(), &|_| Decision::Allow) + ); } #[tokio::test] @@ -335,10 +364,19 @@ mod tests { .await .expect("policy result"); let command = [vec!["ls".to_string()]]; - assert!(matches!( - policy.read().await.check_multiple(command.iter()), - Evaluation::NoMatch { .. } - )); + assert_eq!( + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command: vec!["ls".to_string()], + decision: Decision::Allow + }], + }, + policy + .read() + .await + .check_multiple(command.iter(), &|_| Decision::Allow) + ); } #[tokio::test] @@ -428,7 +466,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") assert_eq!( requirement, ExecApprovalRequirement::Forbidden { - reason: PROMPT_REASON.to_string() + reason: PROMPT_CONFLICT_REASON.to_string() } ); } @@ -457,6 +495,37 @@ prefix_rule(pattern=["rm"], decision="forbidden") ); } + #[tokio::test] + async fn heuristics_apply_when_other_commands_match_policy() { + let policy_src = r#"prefix_rule(pattern=["apple"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.codexpolicy", policy_src) + .expect("parse policy"); + let policy = Arc::new(RwLock::new(parser.build())); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "apple | orange".to_string(), + ]; + + assert_eq!( + create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::DangerFullAccess, + SandboxPermissions::UseDefault, + ) + .await, + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: Some(vec!["orange".to_string()]) + } + ); + } + #[tokio::test] async fn append_allow_prefix_rule_updates_policy_and_file() { let codex_home = tempdir().expect("create temp dir"); @@ -467,14 +536,13 @@ prefix_rule(pattern=["rm"], decision="forbidden") .await .expect("update policy"); - let evaluation = current_policy.read().await.check(&[ - "echo".to_string(), - "hello".to_string(), - "world".to_string(), - ]); + let evaluation = current_policy.read().await.check( + &["echo".to_string(), "hello".to_string(), "world".to_string()], + &|_| Decision::Allow, + ); assert!(matches!( evaluation, - Evaluation::Match { + Evaluation { decision: Decision::Allow, .. } @@ -606,7 +674,38 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: None, - allow_prefix: None, + allow_prefix: Some(vec!["cargo".to_string(), "build".to_string()]), + } + ); + } + + #[tokio::test] + async fn allow_prefix_uses_first_no_match_in_multi_command_scripts() { + let policy_src = r#"prefix_rule(pattern=["python"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.codexpolicy", policy_src) + .expect("parse policy"); + let policy = Arc::new(RwLock::new(parser.build())); + + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "python && echo ok".to_string(), + ]; + + assert_eq!( + create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await, + ExecApprovalRequirement::Skip { + bypass_sandbox: true } ); } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 72a3849a71..76d7e30a2e 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -241,7 +241,6 @@ impl ShellHandler { SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)), ) .await; - let req = ShellRequest { command: exec_params.command.clone(), cwd: exec_params.cwd.clone(), diff --git a/codex-rs/execpolicy/README.md b/codex-rs/execpolicy/README.md index 9fd9c63306..1ddb67252f 100644 --- a/codex-rs/execpolicy/README.md +++ b/codex-rs/execpolicy/README.md @@ -30,32 +30,24 @@ codex execpolicy check --policy path/to/policy.codexpolicy git status cargo run -p codex-execpolicy -- check --policy path/to/policy.codexpolicy git status ``` - Example outcomes: - - Match: `{"match": { ... "decision": "allow" ... }}` - - No match: `{"noMatch": {}}` + - Match: `{"matchedRules":[{...}],"decision":"allow"}` + - No match: `{"matchedRules":[]}` -## Response shapes -- Match: +## Response shape ```json { - "match": { - "decision": "allow|prompt|forbidden", - "matchedRules": [ - { - "prefixRuleMatch": { - "matchedPrefix": ["", "..."], - "decision": "allow|prompt|forbidden" - } + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["", "..."], + "decision": "allow|prompt|forbidden" } - ] - } + } + ], + "decision": "allow|prompt|forbidden" } ``` - -- No match: -```json -{"noMatch": {}} -``` - +- When no rules match, `matchedRules` is an empty array and `decision` is omitted. - `matchedRules` lists every rule whose prefix matched the command; `matchedPrefix` is the exact prefix that matched. - The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`). diff --git a/codex-rs/execpolicy/src/execpolicycheck.rs b/codex-rs/execpolicy/src/execpolicycheck.rs index 0b5e0dcafc..939ed8590b 100644 --- a/codex-rs/execpolicy/src/execpolicycheck.rs +++ b/codex-rs/execpolicy/src/execpolicycheck.rs @@ -4,10 +4,12 @@ use std::path::PathBuf; use anyhow::Context; use anyhow::Result; use clap::Parser; +use serde::Serialize; -use crate::Evaluation; +use crate::Decision; use crate::Policy; use crate::PolicyParser; +use crate::RuleMatch; /// Arguments for evaluating a command against one or more execpolicy files. #[derive(Debug, Parser, Clone)] @@ -34,20 +36,25 @@ impl ExecPolicyCheckCommand { /// Load the policies for this command, evaluate the command, and render JSON output. pub fn run(&self) -> Result<()> { let policy = load_policies(&self.policies)?; - let evaluation = policy.check(&self.command); + let matched_rules = policy.matches_for_command(&self.command, None); - let json = format_evaluation_json(&evaluation, self.pretty)?; + let json = format_matches_json(&matched_rules, self.pretty)?; println!("{json}"); Ok(()) } } -pub fn format_evaluation_json(evaluation: &Evaluation, pretty: bool) -> Result { +pub fn format_matches_json(matched_rules: &[RuleMatch], pretty: bool) -> Result { + let output = ExecPolicyCheckOutput { + matched_rules, + decision: matched_rules.iter().map(RuleMatch::decision).max(), + }; + if pretty { - serde_json::to_string_pretty(evaluation).map_err(Into::into) + serde_json::to_string_pretty(&output).map_err(Into::into) } else { - serde_json::to_string(evaluation).map_err(Into::into) + serde_json::to_string(&output).map_err(Into::into) } } @@ -65,3 +72,12 @@ pub fn load_policies(policy_paths: &[PathBuf]) -> Result { Ok(parser.build()) } + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ExecPolicyCheckOutput<'a> { + #[serde(rename = "matchedRules")] + matched_rules: &'a [RuleMatch], + #[serde(skip_serializing_if = "Option::is_none")] + decision: Option, +} diff --git a/codex-rs/execpolicy/src/main.rs b/codex-rs/execpolicy/src/main.rs index e1373b6d16..d3b34a3307 100644 --- a/codex-rs/execpolicy/src/main.rs +++ b/codex-rs/execpolicy/src/main.rs @@ -1,6 +1,6 @@ use anyhow::Result; use clap::Parser; -use codex_execpolicy::ExecPolicyCheckCommand; +use codex_execpolicy::execpolicycheck::ExecPolicyCheckCommand; /// CLI for evaluating exec policies #[derive(Parser)] @@ -13,10 +13,6 @@ enum Cli { fn main() -> Result<()> { let cli = Cli::parse(); match cli { - Cli::Check(cmd) => cmd_check(cmd), + Cli::Check(cmd) => cmd.run(), } } - -fn cmd_check(cmd: ExecPolicyCheckCommand) -> Result<()> { - cmd.run() -} diff --git a/codex-rs/execpolicy/src/policy.rs b/codex-rs/execpolicy/src/policy.rs index 10858c9fad..991e904ae9 100644 --- a/codex-rs/execpolicy/src/policy.rs +++ b/codex-rs/execpolicy/src/policy.rs @@ -11,6 +11,8 @@ use serde::Deserialize; use serde::Serialize; use std::sync::Arc; +type HeuristicsFallback<'a> = Option<&'a dyn Fn(&[String]) -> Decision>; + #[derive(Clone, Debug)] pub struct Policy { rules_by_program: MultiMap, @@ -50,62 +52,84 @@ impl Policy { Ok(()) } - pub fn check(&self, cmd: &[String]) -> Evaluation { - let rules = match cmd.first() { - Some(first) => match self.rules_by_program.get_vec(first) { - Some(rules) => rules, - None => return Evaluation::NoMatch {}, - }, - None => return Evaluation::NoMatch {}, - }; - - let matched_rules: Vec = - rules.iter().filter_map(|rule| rule.matches(cmd)).collect(); - match matched_rules.iter().map(RuleMatch::decision).max() { - Some(decision) => Evaluation::Match { - decision, - matched_rules, - }, - None => Evaluation::NoMatch {}, - } + pub fn check(&self, cmd: &[String], heuristics_fallback: &F) -> Evaluation + where + F: Fn(&[String]) -> Decision, + { + let matched_rules = self.matches_for_command(cmd, Some(heuristics_fallback)); + Evaluation::from_matches(matched_rules) } - pub fn check_multiple(&self, commands: Commands) -> Evaluation + pub fn check_multiple( + &self, + commands: Commands, + heuristics_fallback: &F, + ) -> Evaluation where Commands: IntoIterator, Commands::Item: AsRef<[String]>, + F: Fn(&[String]) -> Decision, { let matched_rules: Vec = commands .into_iter() - .flat_map(|command| match self.check(command.as_ref()) { - Evaluation::Match { matched_rules, .. } => matched_rules, - Evaluation::NoMatch { .. } => Vec::new(), + .flat_map(|command| { + self.matches_for_command(command.as_ref(), Some(heuristics_fallback)) }) .collect(); - match matched_rules.iter().map(RuleMatch::decision).max() { - Some(decision) => Evaluation::Match { - decision, - matched_rules, - }, - None => Evaluation::NoMatch {}, + Evaluation::from_matches(matched_rules) + } + + pub fn matches_for_command( + &self, + cmd: &[String], + heuristics_fallback: HeuristicsFallback<'_>, + ) -> Vec { + let mut matched_rules: Vec = match cmd.first() { + Some(first) => self + .rules_by_program + .get_vec(first) + .map(|rules| rules.iter().filter_map(|rule| rule.matches(cmd)).collect()) + .unwrap_or_default(), + None => Vec::new(), + }; + + if let (true, Some(heuristics_fallback)) = (matched_rules.is_empty(), heuristics_fallback) { + matched_rules.push(RuleMatch::HeuristicsRuleMatch { + command: cmd.to_vec(), + decision: heuristics_fallback(cmd), + }); } + + matched_rules } } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub enum Evaluation { - NoMatch {}, - Match { - decision: Decision, - #[serde(rename = "matchedRules")] - matched_rules: Vec, - }, +pub struct Evaluation { + pub decision: Decision, + #[serde(rename = "matchedRules")] + pub matched_rules: Vec, } impl Evaluation { pub fn is_match(&self) -> bool { - matches!(self, Self::Match { .. }) + self.matched_rules + .iter() + .any(|rule_match| !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })) + } + + fn from_matches(matched_rules: Vec) -> Self { + let decision = matched_rules + .iter() + .map(RuleMatch::decision) + .max() + .unwrap_or(Decision::Allow); + + Self { + decision, + matched_rules, + } } } diff --git a/codex-rs/execpolicy/src/rule.rs b/codex-rs/execpolicy/src/rule.rs index 20e23fe6a2..cd0756bbb3 100644 --- a/codex-rs/execpolicy/src/rule.rs +++ b/codex-rs/execpolicy/src/rule.rs @@ -64,12 +64,17 @@ pub enum RuleMatch { matched_prefix: Vec, decision: Decision, }, + HeuristicsRuleMatch { + command: Vec, + decision: Decision, + }, } impl RuleMatch { pub fn decision(&self) -> Decision { match self { Self::PrefixRuleMatch { decision, .. } => *decision, + Self::HeuristicsRuleMatch { decision, .. } => *decision, } } } diff --git a/codex-rs/execpolicy/tests/basic.rs b/codex-rs/execpolicy/tests/basic.rs index 9a7ec58b1e..3ea33604ae 100644 --- a/codex-rs/execpolicy/tests/basic.rs +++ b/codex-rs/execpolicy/tests/basic.rs @@ -19,6 +19,14 @@ fn tokens(cmd: &[&str]) -> Vec { cmd.iter().map(std::string::ToString::to_string).collect() } +fn allow_all(_: &[String]) -> Decision { + Decision::Allow +} + +fn prompt_all(_: &[String]) -> Decision { + Decision::Prompt +} + #[derive(Clone, Debug, Eq, PartialEq)] enum RuleSnapshot { Prefix(PrefixRule), @@ -49,9 +57,9 @@ prefix_rule( parser.parse("test.codexpolicy", policy_src)?; let policy = parser.build(); let cmd = tokens(&["git", "status"]); - let evaluation = policy.check(&cmd); + let evaluation = policy.check(&cmd, &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["git", "status"]), @@ -80,9 +88,9 @@ fn add_prefix_rule_extends_policy() -> Result<()> { rules ); - let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"])); + let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Prompt, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["ls", "-l"]), @@ -146,9 +154,9 @@ prefix_rule( git_rules ); - let status_eval = policy.check(&tokens(&["git", "status"])); + let status_eval = policy.check(&tokens(&["git", "status"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Prompt, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["git"]), @@ -158,9 +166,9 @@ prefix_rule( status_eval ); - let commit_eval = policy.check(&tokens(&["git", "commit", "-m", "hi"])); + let commit_eval = policy.check(&tokens(&["git", "commit", "-m", "hi"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Forbidden, matched_rules: vec![ RuleMatch::PrefixRuleMatch { @@ -217,9 +225,9 @@ prefix_rule( sh_rules ); - let bash_eval = policy.check(&tokens(&["bash", "-c", "echo", "hi"])); + let bash_eval = policy.check(&tokens(&["bash", "-c", "echo", "hi"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["bash", "-c"]), @@ -229,9 +237,9 @@ prefix_rule( bash_eval ); - let sh_eval = policy.check(&tokens(&["sh", "-l", "echo", "hi"])); + let sh_eval = policy.check(&tokens(&["sh", "-l", "echo", "hi"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["sh", "-l"]), @@ -273,9 +281,9 @@ prefix_rule( rules ); - let npm_i = policy.check(&tokens(&["npm", "i", "--legacy-peer-deps"])); + let npm_i = policy.check(&tokens(&["npm", "i", "--legacy-peer-deps"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["npm", "i", "--legacy-peer-deps"]), @@ -285,9 +293,12 @@ prefix_rule( npm_i ); - let npm_install = policy.check(&tokens(&["npm", "install", "--no-save", "leftpad"])); + let npm_install = policy.check( + &tokens(&["npm", "install", "--no-save", "leftpad"]), + &allow_all, + ); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["npm", "install", "--no-save"]), @@ -314,9 +325,9 @@ prefix_rule( let mut parser = PolicyParser::new(); parser.parse("test.codexpolicy", policy_src)?; let policy = parser.build(); - let match_eval = policy.check(&tokens(&["git", "status"])); + let match_eval = policy.check(&tokens(&["git", "status"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["git", "status"]), @@ -326,13 +337,20 @@ prefix_rule( match_eval ); - let no_match_eval = policy.check(&tokens(&[ - "git", - "--config", - "color.status=always", - "status", - ])); - assert_eq!(Evaluation::NoMatch {}, no_match_eval); + let no_match_eval = policy.check( + &tokens(&["git", "--config", "color.status=always", "status"]), + &allow_all, + ); + assert_eq!( + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command: tokens(&["git", "--config", "color.status=always", "status",]), + decision: Decision::Allow, + }], + }, + no_match_eval + ); Ok(()) } @@ -352,9 +370,9 @@ prefix_rule( parser.parse("test.codexpolicy", policy_src)?; let policy = parser.build(); - let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"])); + let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Forbidden, matched_rules: vec![ RuleMatch::PrefixRuleMatch { @@ -393,9 +411,9 @@ prefix_rule( tokens(&["git", "commit", "-m", "hi"]), ]; - let evaluation = policy.check_multiple(&commands); + let evaluation = policy.check_multiple(&commands, &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Forbidden, matched_rules: vec![ RuleMatch::PrefixRuleMatch { @@ -416,3 +434,21 @@ prefix_rule( ); Ok(()) } + +#[test] +fn heuristics_match_is_returned_when_no_policy_matches() { + let policy = Policy::empty(); + let command = tokens(&["python"]); + + let evaluation = policy.check(&command, &prompt_all); + assert_eq!( + Evaluation { + decision: Decision::Prompt, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Prompt, + }], + }, + evaluation + ); +} diff --git a/docs/execpolicy.md b/docs/execpolicy.md index a5b77e402e..77d8ed0e7b 100644 --- a/docs/execpolicy.md +++ b/docs/execpolicy.md @@ -33,6 +33,30 @@ codex execpolicy check --policy ~/.codex/policy/default.codexpolicy git push ori Pass multiple `--policy` flags to test how several files combine, and use `--pretty` for formatted JSON output. See the [`codex-rs/execpolicy` README](../codex-rs/execpolicy/README.md) for a more detailed walkthrough of the available syntax. +Example output when a rule matches: + +```json +{ + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["git", "push"], + "decision": "prompt" + } + } + ], + "decision": "prompt" +} +``` + +When no rules match, `matchedRules` is an empty array and `decision` is omitted. + +```json +{ + "matchedRules": [], +} + ## Status `execpolicy` commands are still in preview. The API may have breaking changes in the future. +```