diff --git a/README.md b/README.md index e34afbd288..2d5e96d251 100644 --- a/README.md +++ b/README.md @@ -94,13 +94,13 @@ In this example rule, if Codex wants to run commands with the prefix `git push` Note: If Codex wants to run a command that matches with multiple rules, it will use the strictest decision among the matched rules (forbidden > prompt > allow). -Use the [`execpolicy2` CLI](./codex-rs/execpolicy2/README.md) to preview decisions before you save a rule: +Use the `codex execpolicycheck` subcommand to preview decisions before you save a rule (see the [`execpolicy2` README](./codex-rs/execpolicy2/README.md) for syntax details): ```shell -cargo run -p codex-execpolicy2 -- check --policy ~/.codex/policy/default.codexpolicy git push origin main +codex execpolicycheck --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/execpolicy2` README](./codex-rs/execpolicy2/README.md) for a more detailed walkthrough of the available syntax. --- diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index af83236a57..030568ba8c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -989,6 +989,7 @@ dependencies = [ "codex-common", "codex-core", "codex-exec", + "codex-execpolicy2", "codex-login", "codex-mcp-server", "codex-process-hardening", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index deddc068cd..6856573747 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-execpolicy2 = { 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 6a3b24aa98..a234cdb8ec 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use clap::Args; use clap::CommandFactory; use clap::Parser; @@ -18,11 +19,13 @@ 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_execpolicy2::PolicyParser; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use codex_tui::update_action::UpdateAction; use owo_colors::OwoColorize; +use std::fs; use std::path::PathBuf; use supports_color::Stream; @@ -112,6 +115,10 @@ enum Subcommand { #[clap(hide = true, name = "stdio-to-uds")] StdioToUds(StdioToUdsCommand), + /// Check execpolicy files against a command. + #[clap(name = "execpolicycheck")] + ExecPolicyCheck(ExecPolicyCheckCommand), + /// Inspect feature flags. Features(FeaturesCli), } @@ -246,6 +253,26 @@ struct StdioToUdsCommand { socket_path: PathBuf, } +#[derive(Debug, Parser)] +struct ExecPolicyCheckCommand { + /// Paths to execpolicy files to evaluate (repeatable). + #[arg(short, long = "policy", value_name = "PATH", required = true)] + policies: Vec, + + /// Pretty-print the JSON output. + #[arg(long)] + pretty: bool, + + /// Command tokens to check against the policy. + #[arg( + value_name = "COMMAND", + required = true, + trailing_var_arg = true, + allow_hyphen_values = true + )] + command: Vec, +} + fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { let AppExitInfo { token_usage, @@ -323,6 +350,35 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { Ok(()) } +fn run_execpolicy_check(cli: ExecPolicyCheckCommand) -> anyhow::Result<()> { + let policy = load_policies(&cli.policies)?; + let evaluation = policy.check(&cli.command); + + let json = if cli.pretty { + serde_json::to_string_pretty(&evaluation)? + } else { + serde_json::to_string(&evaluation)? + }; + + println!("{json}"); + Ok(()) +} + +fn load_policies(policy_paths: &[PathBuf]) -> anyhow::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()) +} + #[derive(Debug, Default, Parser, Clone)] struct FeatureToggles { /// Enable a feature (repeatable). Equivalent to `-c features.=true`. @@ -559,6 +615,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() tokio::task::spawn_blocking(move || codex_stdio_to_uds::run(socket_path.as_path())) .await??; } + Some(Subcommand::ExecPolicyCheck(execpolicy_cli)) => { + run_execpolicy_check(execpolicy_cli)?; + } Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { // Respect root-level `-c` overrides plus top-level flags like `--profile`. diff --git a/codex-rs/cli/tests/execpolicycheck.rs b/codex-rs/cli/tests/execpolicycheck.rs new file mode 100644 index 0000000000..cc042a4e6f --- /dev/null +++ b/codex-rs/cli/tests/execpolicycheck.rs @@ -0,0 +1,59 @@ +use std::fs; +use std::path::Path; + +use anyhow::Result; +use assert_cmd::Command; +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use serde_json::json; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = Command::cargo_bin("codex")?; + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[test] +fn execpolicycheck_evaluates_command() -> Result<()> { + let codex_home = TempDir::new()?; + let policy_path = codex_home.path().join("policy.codexpolicy"); + fs::write( + &policy_path, + r#" +prefix_rule( + pattern = ["echo"], + decision = "forbidden", +) +"#, + )?; + + let mut cmd = codex_command(codex_home.path())?; + let output = cmd + .args([ + "execpolicycheck", + "--policy", + policy_path + .to_str() + .expect("policy path should be valid UTF-8"), + "--pretty", + "echo", + "hello", + ]) + .output()?; + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + let parsed: JsonValue = serde_json::from_str(&stdout)?; + let matched = parsed + .get("match") + .cloned() + .expect("match result should be present"); + assert_eq!(matched["decision"], "forbidden"); + assert_eq!( + matched["matchedRules"][0]["prefixRuleMatch"]["matchedPrefix"], + json!(["echo"]) + ); + + Ok(()) +} diff --git a/codex-rs/execpolicy2/README.md b/codex-rs/execpolicy2/README.md index d77ec3838d..96e67b1e0a 100644 --- a/codex-rs/execpolicy2/README.md +++ b/codex-rs/execpolicy2/README.md @@ -19,15 +19,19 @@ prefix_rule( ``` ## CLI -- Provide one or more policy files (for example `src/default.codexpolicy`) to check a command: +- From the Codex CLI, run `codex execpolicycheck` with one or more policy files (for example `src/default.codexpolicy`) to check a command: ```bash -cargo run -p codex-execpolicy2 -- check --policy path/to/policy.codexpolicy git status +codex execpolicycheck --policy path/to/policy.codexpolicy git status ``` - Pass multiple `--policy` flags to merge rules, evaluated in the order provided: ```bash -cargo run -p codex-execpolicy2 -- check --policy base.codexpolicy --policy overrides.codexpolicy git status +codex execpolicycheck --policy base.codexpolicy --policy overrides.codexpolicy git status ``` - Output is JSON by default; pass `--pretty` for pretty-printed JSON +- You can also run the standalone dev binary directly during development: +```bash +cargo run -p codex-execpolicy2 -- check --policy path/to/policy.codexpolicy git status +``` - Example outcomes: - Match: `{"match": { ... "decision": "allow" ... }}` - No match: `"noMatch"`