diff --git a/README.md b/README.md index 4fed27640..b90e6d6d7 100644 --- a/README.md +++ b/README.md @@ -92,15 +92,15 @@ prefix_rule( In this example rule, if Codex wants to run commands with the prefix `git push` or `git fetch`, it will first ask for user approval. -Use [`execpolicy2` CLI](./codex-rs/execpolicy2/README.md) to preview decisions for policy files: +Use the `codex execpolicy check` subcommand to preview decisions before you save a rule (see the [`codex-execpolicy` README](./codex-rs/execpolicy/README.md) for syntax details): ```shell -cargo run -p codex-execpolicy2 -- check --policy ~/.codex/policy/default.codexpolicy git push origin main +codex execpolicy check --policy ~/.codex/policy/default.codexpolicy git push origin main ``` -Pass multiple `--policy` flags to test how several files combine. See the [`codex-rs/execpolicy2` README](./codex-rs/execpolicy2/README.md) for a more detailed walkthrough of the available syntax. +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. ---- +## Note: `execpolicy` commands are still in preview. The API may have breaking changes in the future. ### Docs & FAQ diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fbbdb3d1f..02cb93ea6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1001,6 +1001,7 @@ dependencies = [ "codex-common", "codex-core", "codex-exec", + "codex-execpolicy", "codex-login", "codex-mcp-server", "codex-process-hardening", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index deddc068c..e7999b5ce 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -26,6 +26,7 @@ codex-cloud-tasks = { path = "../cloud-tasks" } codex-common = { workspace = true, features = ["cli"] } codex-core = { workspace = true } codex-exec = { workspace = true } +codex-execpolicy = { workspace = true } codex-login = { workspace = true } codex-mcp-server = { workspace = true } codex-process-hardening = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index bb60870de..2b066197a 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -18,6 +18,7 @@ use codex_cli::login::run_logout; use codex_cloud_tasks::Cli as CloudTasksCli; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; +use codex_execpolicy::ExecPolicyCheckCommand; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; @@ -93,6 +94,10 @@ enum Subcommand { #[clap(visible_alias = "debug")] Sandbox(SandboxArgs), + /// Execpolicy tooling. + #[clap(hide = true)] + Execpolicy(ExecpolicyCommand), + /// Apply the latest diff produced by Codex agent as a `git apply` to your local working tree. #[clap(visible_alias = "a")] Apply(ApplyCommand), @@ -162,6 +167,19 @@ enum SandboxCommand { Windows(WindowsCommand), } +#[derive(Debug, Parser)] +struct ExecpolicyCommand { + #[command(subcommand)] + sub: ExecpolicySubcommand, +} + +#[derive(Debug, clap::Subcommand)] +enum ExecpolicySubcommand { + /// Check execpolicy files against a command. + #[clap(name = "check")] + Check(ExecPolicyCheckCommand), +} + #[derive(Debug, Parser)] struct LoginCommand { #[clap(skip)] @@ -327,6 +345,10 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { Ok(()) } +fn run_execpolicycheck(cmd: ExecPolicyCheckCommand) -> anyhow::Result<()> { + cmd.run() +} + #[derive(Debug, Default, Parser, Clone)] struct FeatureToggles { /// Enable a feature (repeatable). Equivalent to `-c features.=true`. @@ -549,6 +571,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() .await?; } }, + Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub { + ExecpolicySubcommand::Check(cmd) => run_execpolicycheck(cmd)?, + }, Some(Subcommand::Apply(mut apply_cli)) => { prepend_config_flags( &mut apply_cli.config_overrides, diff --git a/codex-rs/cli/tests/execpolicy.rs b/codex-rs/cli/tests/execpolicy.rs new file mode 100644 index 000000000..c6bca85bc --- /dev/null +++ b/codex-rs/cli/tests/execpolicy.rs @@ -0,0 +1,58 @@ +use std::fs; + +use assert_cmd::Command; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; + +#[test] +fn execpolicy_check_matches_expected_json() -> Result<(), Box> { + let codex_home = TempDir::new()?; + let policy_path = codex_home.path().join("policy.codexpolicy"); + fs::write( + &policy_path, + r#" +prefix_rule( + pattern = ["git", "push"], + decision = "forbidden", +) +"#, + )?; + + let output = Command::cargo_bin("codex")? + .env("CODEX_HOME", codex_home.path()) + .args([ + "execpolicy", + "check", + "--policy", + policy_path + .to_str() + .expect("policy path should be valid UTF-8"), + "git", + "push", + "origin", + "main", + ]) + .output()?; + + assert!(output.status.success()); + let result: serde_json::Value = serde_json::from_slice(&output.stdout)?; + assert_eq!( + result, + json!({ + "match": { + "decision": "forbidden", + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["git", "push"], + "decision": "forbidden" + } + } + ] + } + }) + ); + + Ok(()) +} diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 9525cc1ac..2a5d3904e 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -109,7 +109,7 @@ fn evaluate_with_policy( } Decision::Allow => Some(ApprovalRequirement::Skip), }, - Evaluation::NoMatch => None, + Evaluation::NoMatch { .. } => None, } } @@ -206,7 +206,7 @@ mod tests { let commands = [vec!["rm".to_string()]]; assert!(matches!( policy.check_multiple(commands.iter()), - Evaluation::NoMatch + Evaluation::NoMatch { .. } )); assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists()); } @@ -259,7 +259,7 @@ mod tests { let command = [vec!["ls".to_string()]]; assert!(matches!( policy.check_multiple(command.iter()), - Evaluation::NoMatch + Evaluation::NoMatch { .. } )); } diff --git a/codex-rs/execpolicy/README.md b/codex-rs/execpolicy/README.md index f508c8ca6..9fd9c6330 100644 --- a/codex-rs/execpolicy/README.md +++ b/codex-rs/execpolicy/README.md @@ -20,18 +20,18 @@ prefix_rule( ``` ## CLI -- Provide one or more policy files (for example `src/default.codexpolicy`) to check a command: +- From the Codex CLI, run `codex execpolicy check` subcommand with one or more policy files (for example `src/default.codexpolicy`) to check a command: +```bash +codex execpolicy check --policy path/to/policy.codexpolicy git status +``` +- Pass multiple `--policy` flags to merge rules, evaluated in the order provided, and use `--pretty` for formatted JSON. +- You can also run the standalone dev binary directly during development: ```bash cargo run -p codex-execpolicy -- check --policy path/to/policy.codexpolicy git status ``` -- Pass multiple `--policy` flags to merge rules, evaluated in the order provided: -```bash -cargo run -p codex-execpolicy -- check --policy base.codexpolicy --policy overrides.codexpolicy git status -``` -- Output is JSON by default; pass `--pretty` for pretty-printed JSON - Example outcomes: - Match: `{"match": { ... "decision": "allow" ... }}` - - No match: `"noMatch"` + - No match: `{"noMatch": {}}` ## Response shapes - Match: @@ -53,8 +53,10 @@ cargo run -p codex-execpolicy -- check --policy base.codexpolicy --policy overri - No match: ```json -"noMatch" +{"noMatch": {}} ``` - `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`). + +Note: `execpolicy` commands are still in preview. The API may have breaking changes in the future. diff --git a/codex-rs/execpolicy/src/execpolicycheck.rs b/codex-rs/execpolicy/src/execpolicycheck.rs new file mode 100644 index 000000000..0b5e0dcaf --- /dev/null +++ b/codex-rs/execpolicy/src/execpolicycheck.rs @@ -0,0 +1,67 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use clap::Parser; + +use crate::Evaluation; +use crate::Policy; +use crate::PolicyParser; + +/// Arguments for evaluating a command against one or more execpolicy files. +#[derive(Debug, Parser, Clone)] +pub struct ExecPolicyCheckCommand { + /// Paths to execpolicy files to evaluate (repeatable). + #[arg(short = 'p', long = "policy", value_name = "PATH", required = true)] + pub policies: Vec, + + /// Pretty-print the JSON output. + #[arg(long)] + pub pretty: bool, + + /// Command tokens to check against the policy. + #[arg( + value_name = "COMMAND", + required = true, + trailing_var_arg = true, + allow_hyphen_values = true + )] + pub command: Vec, +} + +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 json = format_evaluation_json(&evaluation, self.pretty)?; + println!("{json}"); + + Ok(()) + } +} + +pub fn format_evaluation_json(evaluation: &Evaluation, pretty: bool) -> Result { + if pretty { + serde_json::to_string_pretty(evaluation).map_err(Into::into) + } else { + serde_json::to_string(evaluation).map_err(Into::into) + } +} + +pub fn load_policies(policy_paths: &[PathBuf]) -> Result { + let mut parser = PolicyParser::new(); + + for policy_path in policy_paths { + let policy_file_contents = fs::read_to_string(policy_path) + .with_context(|| format!("failed to read policy at {}", policy_path.display()))?; + let policy_identifier = policy_path.to_string_lossy().to_string(); + parser + .parse(&policy_identifier, &policy_file_contents) + .with_context(|| format!("failed to parse policy at {}", policy_path.display()))?; + } + + Ok(parser.build()) +} diff --git a/codex-rs/execpolicy/src/lib.rs b/codex-rs/execpolicy/src/lib.rs index 1b789fd86..b459caea1 100644 --- a/codex-rs/execpolicy/src/lib.rs +++ b/codex-rs/execpolicy/src/lib.rs @@ -1,5 +1,6 @@ pub mod decision; pub mod error; +pub mod execpolicycheck; pub mod parser; pub mod policy; pub mod rule; @@ -7,6 +8,7 @@ pub mod rule; pub use decision::Decision; pub use error::Error; pub use error::Result; +pub use execpolicycheck::ExecPolicyCheckCommand; pub use parser::PolicyParser; pub use policy::Evaluation; pub use policy::Policy; diff --git a/codex-rs/execpolicy/src/main.rs b/codex-rs/execpolicy/src/main.rs index 318abb5ca..e1373b6d1 100644 --- a/codex-rs/execpolicy/src/main.rs +++ b/codex-rs/execpolicy/src/main.rs @@ -1,66 +1,22 @@ -use std::fs; -use std::path::PathBuf; - -use anyhow::Context; use anyhow::Result; use clap::Parser; -use codex_execpolicy::PolicyParser; +use codex_execpolicy::ExecPolicyCheckCommand; /// CLI for evaluating exec policies #[derive(Parser)] #[command(name = "codex-execpolicy")] enum Cli { /// Evaluate a command against a policy. - Check { - #[arg(short, long = "policy", value_name = "PATH", required = true)] - policies: Vec, - - /// Pretty-print the JSON output. - #[arg(long)] - pretty: bool, - - /// Command tokens to check. - #[arg( - value_name = "COMMAND", - required = true, - trailing_var_arg = true, - allow_hyphen_values = true - )] - command: Vec, - }, + Check(ExecPolicyCheckCommand), } fn main() -> Result<()> { let cli = Cli::parse(); match cli { - Cli::Check { - policies, - command, - pretty, - } => cmd_check(policies, command, pretty), + Cli::Check(cmd) => cmd_check(cmd), } } -fn cmd_check(policy_paths: Vec, args: Vec, pretty: bool) -> Result<()> { - let policy = load_policies(&policy_paths)?; - - let eval = policy.check(&args); - let json = if pretty { - serde_json::to_string_pretty(&eval)? - } else { - serde_json::to_string(&eval)? - }; - println!("{json}"); - Ok(()) -} - -fn load_policies(policy_paths: &[PathBuf]) -> Result { - let mut parser = PolicyParser::new(); - for policy_path in policy_paths { - let policy_file_contents = fs::read_to_string(policy_path) - .with_context(|| format!("failed to read policy at {}", policy_path.display()))?; - let policy_identifier = policy_path.to_string_lossy().to_string(); - parser.parse(&policy_identifier, &policy_file_contents)?; - } - Ok(parser.build()) +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 a543e5c4b..e048fce1f 100644 --- a/codex-rs/execpolicy/src/policy.rs +++ b/codex-rs/execpolicy/src/policy.rs @@ -27,9 +27,9 @@ impl Policy { 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 {}, }, - None => return Evaluation::NoMatch, + None => return Evaluation::NoMatch {}, }; let matched_rules: Vec = @@ -39,7 +39,7 @@ impl Policy { decision, matched_rules, }, - None => Evaluation::NoMatch, + None => Evaluation::NoMatch {}, } } @@ -52,7 +52,7 @@ impl Policy { .into_iter() .flat_map(|command| match self.check(command.as_ref()) { Evaluation::Match { matched_rules, .. } => matched_rules, - Evaluation::NoMatch => Vec::new(), + Evaluation::NoMatch { .. } => Vec::new(), }) .collect(); @@ -61,7 +61,7 @@ impl Policy { decision, matched_rules, }, - None => Evaluation::NoMatch, + None => Evaluation::NoMatch {}, } } } @@ -69,7 +69,7 @@ impl Policy { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Evaluation { - NoMatch, + NoMatch {}, Match { decision: Decision, #[serde(rename = "matchedRules")] diff --git a/codex-rs/execpolicy/tests/basic.rs b/codex-rs/execpolicy/tests/basic.rs index 301f96261..e4189caa2 100644 --- a/codex-rs/execpolicy/tests/basic.rs +++ b/codex-rs/execpolicy/tests/basic.rs @@ -288,7 +288,7 @@ prefix_rule( "color.status=always", "status", ])); - assert_eq!(Evaluation::NoMatch, no_match_eval); + assert_eq!(Evaluation::NoMatch {}, no_match_eval); } #[test]