Skip to main content

codex/
execpolicy.rs

1use std::{
2    collections::BTreeMap,
3    ffi::OsString,
4    path::PathBuf,
5    process::{ExitStatus, Stdio},
6};
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use tokio::{process::Command, time};
11
12use super::{
13    apply_cli_overrides, resolve_cli_overrides, spawn_with_retry, tee_stream, CliOverridesPatch,
14    CodexClient, CodexError, ConfigOverride, ConsoleTarget, FlagState,
15};
16
17/// Decision returned by execpolicy evaluation.
18#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
19#[serde(rename_all = "lowercase")]
20pub enum ExecPolicyDecision {
21    Allow,
22    Prompt,
23    Forbidden,
24}
25
26/// Matched rule entry returned by `codex execpolicy check`.
27#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
28#[serde(rename_all = "camelCase")]
29pub struct ExecPolicyRuleMatch {
30    /// Optional rule name/identifier.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub name: Option<String>,
33    /// Human-readable description when provided by the policy.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub description: Option<String>,
36    /// Decision attached to the rule. Defaults to [`ExecPolicyDecision::Allow`] when omitted.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub decision: Option<ExecPolicyDecision>,
39    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
40    pub extra: BTreeMap<String, Value>,
41}
42
43/// Matched execpolicy summary with the merged decision and contributing rules.
44#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
45#[serde(rename_all = "camelCase")]
46pub struct ExecPolicyMatch {
47    pub decision: ExecPolicyDecision,
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub rules: Vec<ExecPolicyRuleMatch>,
50    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
51    pub extra: BTreeMap<String, Value>,
52}
53
54/// Response returned when no rules matched.
55#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct ExecPolicyNoMatch {
58    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
59    pub extra: BTreeMap<String, Value>,
60}
61
62/// Parsed output from `codex execpolicy check`.
63#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct ExecPolicyEvaluation {
66    #[serde(rename = "match", default, skip_serializing_if = "Option::is_none")]
67    pub match_result: Option<ExecPolicyMatch>,
68    #[serde(rename = "noMatch", default, skip_serializing_if = "Option::is_none")]
69    pub no_match: Option<ExecPolicyNoMatch>,
70}
71
72impl ExecPolicyEvaluation {
73    /// Returns the top-level decision when a policy matched.
74    pub fn decision(&self) -> Option<ExecPolicyDecision> {
75        self.match_result.as_ref().map(|result| result.decision)
76    }
77}
78
79/// Captured output from `codex execpolicy check`.
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct ExecPolicyCheckResult {
82    /// Exit status returned by the subcommand.
83    pub status: ExitStatus,
84    /// Captured stdout (mirrored to the console when `mirror_stdout` is true).
85    pub stdout: String,
86    /// Captured stderr (mirrored unless `quiet` is set).
87    pub stderr: String,
88    /// Parsed decision JSON.
89    pub evaluation: ExecPolicyEvaluation,
90}
91
92impl ExecPolicyCheckResult {
93    /// Convenience accessor for the matched decision (if any).
94    pub fn decision(&self) -> Option<ExecPolicyDecision> {
95        self.evaluation.decision()
96    }
97}
98
99/// Request to evaluate a command against Starlark execpolicy files.
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct ExecPolicyCheckRequest {
102    /// One or more `.codexpolicy` files to merge with repeatable `--policy` flags.
103    pub policies: Vec<PathBuf>,
104    /// Pretty-print JSON output (`--pretty`).
105    pub pretty: bool,
106    /// Command argv forwarded after `--`. Must not be empty.
107    pub command: Vec<OsString>,
108    /// Per-call CLI overrides layered on top of the builder.
109    pub overrides: CliOverridesPatch,
110}
111
112impl ExecPolicyCheckRequest {
113    pub fn new<I, S>(command: I) -> Self
114    where
115        I: IntoIterator<Item = S>,
116        S: Into<OsString>,
117    {
118        Self {
119            policies: Vec::new(),
120            pretty: false,
121            command: command.into_iter().map(Into::into).collect(),
122            overrides: CliOverridesPatch::default(),
123        }
124    }
125
126    /// Adds a single `--policy` path.
127    pub fn policy(mut self, policy: impl Into<PathBuf>) -> Self {
128        self.policies.push(policy.into());
129        self
130    }
131
132    /// Adds multiple `--policy` paths.
133    pub fn policies<I, P>(mut self, policies: I) -> Self
134    where
135        I: IntoIterator<Item = P>,
136        P: Into<PathBuf>,
137    {
138        self.policies
139            .extend(policies.into_iter().map(|policy| policy.into()));
140        self
141    }
142
143    /// Controls whether `--pretty` is forwarded.
144    pub fn pretty(mut self, enable: bool) -> Self {
145        self.pretty = enable;
146        self
147    }
148
149    /// Replaces the default CLI overrides for this request.
150    pub fn with_overrides(mut self, overrides: CliOverridesPatch) -> Self {
151        self.overrides = overrides;
152        self
153    }
154
155    /// Adds a `--config key=value` override for this request.
156    pub fn config_override(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
157        self.overrides
158            .config_overrides
159            .push(ConfigOverride::new(key, value));
160        self
161    }
162
163    /// Adds a raw `--config key=value` override without validation.
164    pub fn config_override_raw(mut self, raw: impl Into<String>) -> Self {
165        self.overrides
166            .config_overrides
167            .push(ConfigOverride::from_raw(raw));
168        self
169    }
170
171    /// Sets the config profile (`--profile`) for this request.
172    pub fn profile(mut self, profile: impl Into<String>) -> Self {
173        let profile = profile.into();
174        self.overrides.profile = (!profile.trim().is_empty()).then_some(profile);
175        self
176    }
177
178    /// Requests the CLI `--oss` flag for this call.
179    pub fn oss(mut self, enable: bool) -> Self {
180        self.overrides.oss = if enable {
181            FlagState::Enable
182        } else {
183            FlagState::Disable
184        };
185        self
186    }
187
188    /// Adds a `--enable <feature>` toggle for this call.
189    pub fn enable_feature(mut self, name: impl Into<String>) -> Self {
190        self.overrides.feature_toggles.enable.push(name.into());
191        self
192    }
193
194    /// Adds a `--disable <feature>` toggle for this call.
195    pub fn disable_feature(mut self, name: impl Into<String>) -> Self {
196        self.overrides.feature_toggles.disable.push(name.into());
197        self
198    }
199
200    /// Controls whether `--search` is passed through to Codex.
201    pub fn search(mut self, enable: bool) -> Self {
202        self.overrides.search = if enable {
203            FlagState::Enable
204        } else {
205            FlagState::Disable
206        };
207        self
208    }
209}
210
211impl CodexClient {
212    /// Evaluates a command against Starlark execpolicy files via `codex execpolicy check`.
213    ///
214    /// Forwards repeatable `--policy` paths, optional `--pretty`, and builder/request CLI overrides
215    /// (config/profile/approval/sandbox/local-provider/cd/search). Captures stdout/stderr according to the
216    /// builder, returns parsed JSON, and surfaces non-zero exits as [`CodexError::NonZeroExit`].
217    /// Empty command argv returns [`CodexError::EmptyExecPolicyCommand`].
218    pub async fn check_execpolicy(
219        &self,
220        request: ExecPolicyCheckRequest,
221    ) -> Result<ExecPolicyCheckResult, CodexError> {
222        if request.command.is_empty() {
223            return Err(CodexError::EmptyExecPolicyCommand);
224        }
225
226        let ExecPolicyCheckRequest {
227            policies,
228            pretty,
229            command,
230            overrides,
231        } = request;
232
233        let dir_ctx = self.directory_context()?;
234        let resolved_overrides =
235            resolve_cli_overrides(&self.cli_overrides, &overrides, self.model.as_deref());
236
237        let mut process = Command::new(self.command_env.binary_path());
238        process
239            .arg("execpolicy")
240            .arg("check")
241            .stdout(Stdio::piped())
242            .stderr(Stdio::piped())
243            .kill_on_drop(true)
244            .current_dir(dir_ctx.path());
245
246        for policy in policies {
247            process.arg("--policy").arg(policy);
248        }
249
250        if pretty {
251            process.arg("--pretty");
252        }
253
254        apply_cli_overrides(&mut process, &resolved_overrides, true);
255
256        process.arg("--");
257        process.args(&command);
258
259        self.command_env.apply(&mut process)?;
260
261        let mut child = spawn_with_retry(&mut process, self.command_env.binary_path())?;
262
263        let stdout = child.stdout.take().ok_or(CodexError::StdoutUnavailable)?;
264        let stderr = child.stderr.take().ok_or(CodexError::StderrUnavailable)?;
265
266        let stdout_task = tokio::spawn(tee_stream(
267            stdout,
268            ConsoleTarget::Stdout,
269            self.mirror_stdout,
270        ));
271        let stderr_task = tokio::spawn(tee_stream(stderr, ConsoleTarget::Stderr, !self.quiet));
272
273        let wait_task = async move {
274            let status = child
275                .wait()
276                .await
277                .map_err(|source| CodexError::Wait { source })?;
278            let stdout_bytes = stdout_task
279                .await
280                .map_err(CodexError::Join)?
281                .map_err(CodexError::CaptureIo)?;
282            let stderr_bytes = stderr_task
283                .await
284                .map_err(CodexError::Join)?
285                .map_err(CodexError::CaptureIo)?;
286            Ok::<_, CodexError>((status, stdout_bytes, stderr_bytes))
287        };
288
289        let (status, stdout_bytes, stderr_bytes) = if self.timeout.is_zero() {
290            wait_task.await?
291        } else {
292            match time::timeout(self.timeout, wait_task).await {
293                Ok(result) => result?,
294                Err(_) => {
295                    return Err(CodexError::Timeout {
296                        timeout: self.timeout,
297                    });
298                }
299            }
300        };
301
302        let stdout_string = String::from_utf8(stdout_bytes)?;
303        let stderr_string = String::from_utf8(stderr_bytes)?;
304
305        if !status.success() {
306            return Err(CodexError::NonZeroExit {
307                status,
308                stderr: stderr_string,
309            });
310        }
311
312        let evaluation: ExecPolicyEvaluation =
313            serde_json::from_str(&stdout_string).map_err(|source| CodexError::ExecPolicyParse {
314                stdout: stdout_string.clone(),
315                source,
316            })?;
317
318        Ok(ExecPolicyCheckResult {
319            status,
320            stdout: stdout_string,
321            stderr: stderr_string,
322            evaluation,
323        })
324    }
325}