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            .stdout(Stdio::piped())
240            .stderr(Stdio::piped())
241            .kill_on_drop(true)
242            .current_dir(dir_ctx.path());
243
244        apply_cli_overrides(&mut process, &resolved_overrides, true);
245        process.arg("execpolicy").arg("check");
246
247        for policy in policies {
248            process.arg("--policy").arg(policy);
249        }
250
251        if pretty {
252            process.arg("--pretty");
253        }
254
255        process.arg("--");
256        process.args(&command);
257
258        self.command_env.apply(&mut process)?;
259
260        let mut child = spawn_with_retry(&mut process, self.command_env.binary_path())?;
261
262        let stdout = child.stdout.take().ok_or(CodexError::StdoutUnavailable)?;
263        let stderr = child.stderr.take().ok_or(CodexError::StderrUnavailable)?;
264
265        let stdout_task = tokio::spawn(tee_stream(
266            stdout,
267            ConsoleTarget::Stdout,
268            self.mirror_stdout,
269        ));
270        let stderr_task = tokio::spawn(tee_stream(stderr, ConsoleTarget::Stderr, !self.quiet));
271
272        let wait_task = async move {
273            let status = child
274                .wait()
275                .await
276                .map_err(|source| CodexError::Wait { source })?;
277            let stdout_bytes = stdout_task
278                .await
279                .map_err(CodexError::Join)?
280                .map_err(CodexError::CaptureIo)?;
281            let stderr_bytes = stderr_task
282                .await
283                .map_err(CodexError::Join)?
284                .map_err(CodexError::CaptureIo)?;
285            Ok::<_, CodexError>((status, stdout_bytes, stderr_bytes))
286        };
287
288        let (status, stdout_bytes, stderr_bytes) = if self.timeout.is_zero() {
289            wait_task.await?
290        } else {
291            match time::timeout(self.timeout, wait_task).await {
292                Ok(result) => result?,
293                Err(_) => {
294                    return Err(CodexError::Timeout {
295                        timeout: self.timeout,
296                    });
297                }
298            }
299        };
300
301        let stdout_string = String::from_utf8(stdout_bytes)?;
302        let stderr_string = String::from_utf8(stderr_bytes)?;
303
304        if !status.success() {
305            return Err(CodexError::NonZeroExit {
306                status,
307                stderr: stderr_string,
308            });
309        }
310
311        let evaluation: ExecPolicyEvaluation =
312            serde_json::from_str(&stdout_string).map_err(|source| CodexError::ExecPolicyParse {
313                stdout: stdout_string.clone(),
314                source,
315            })?;
316
317        Ok(ExecPolicyCheckResult {
318            status,
319            stdout: stdout_string,
320            stderr: stderr_string,
321            evaluation,
322        })
323    }
324}